Files
acac97b07d phpbox: the node survives any host and keeps the tunnel up across generations (#143)
* phpbox: survive any host's limits and keep streams alive across handovers

Found on a local emulation of a free host (Apache + PHP-FPM, 60 s CPU per request,
the host's disabled functions, a gateway that gives up on the request):
- disabled set_time_limit/ignore_user_abort/getmypid/getenv/ini_set ended the node
  at once under PHP 8 (a disabled function is undefined): guarded everywhere; ping
  lists what the host took away
- time budget per generation on both clocks (CPU on Linux, wall on Windows/macOS,
  RLIMIT_CPU), handover early when CPU runs short, and learning: a generation that
  died early leaves its age in host.json and the next ones plan below it; on a host
  not yet proven, handovers start at 40 s and grow as generations end well
- cups: the carrier slept 18 ms per message inside the loop (a 64 KB frame ~0.4 s),
  so under a download new connections timed out and acks waited; now a paced
  outbox in the mux: control frames first, streams in turn, whole packets per
  message, at most ~72 KB/s (the server drops a member that sends faster)
- two generations in one room: each now ignores the other's messages (and its own
  echoes, before decoding them), and messages carry whole packets, so a packet cut
  across messages is never mixed with another member's bytes; the Go cupsonline
  receiver keeps one reassembly buffer per member
- a successor takes streams only after its link stayed up 8 s (a Mail.ru document
  drops new connections right after they join, and streams handed over then died)
- a repeated OPEN is answered again instead of redialled; the client asks again
  after 4 s without an answer
- the page restarts a node that ended without Stop while it is open
- --mode=stream without --inbound is SOCKS5 on every OS again (macOS took utun)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* phpbox: send to cups only as fast as the server confirms; release rebuilds an existing tag

- the cups server shows every member's message back to it; our own coming back is
  its confirmation. At most 6 unconfirmed messages: past that the server is behind,
  and the client's OPENs and its pings queued behind our echoes (close 3012
  'no pong' under a download). The link's close code and reason go to the log.
- release.yml: a moved tag replaces the release's assets and notes, as the apps do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* phpbox: count the cups server's echoes reliably; learn a host limit only from two deaths

- echoes were matched with a regex on the raw line that never matched the server's
  JSON, so the window waited out its 3 s timeout each time (~6 KB/s under load):
  a substring test on the member uuid instead; a live room confirms 10 of 10
- one sudden end (a host restart) no longer teaches a limit that shortens every
  run for a week: two ends at about the same age do

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* phpbox: hand over early and consistently; never reach for a longer run than the host allows

The emulated hosts exposed the real cause of the node dying: the 'hold idle to prove a
longer run' recorded a lifetime (107s) longer than a host's hidden wall-clock kill (90s),
so later generations set their handover past that limit and were killed before they could
start a successor - the chain broke with no generation left to carry the lesson.

- a generation now hands over at ~2/3 of the known limit, never later than 45s, even on a
  host that looks generous: a free host's real limit is unknown until a generation dies by
  it, and that death must happen after a successor exists. Deaths only lower it.
- removed the idle hold and the lifetime-growth; a learned wall/CPU limit still lowers the
  handover, and a wall limit is believed only after two deaths at a similar age
- while a successor shares the room, the predecessor caps its reading (OVERLAP_RATE): the
  document server copies our download to the successor too, and at full speed the client's
  OPENs to the successor waited behind it

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* CHANGELOG: the node-survival and stream-proxy fixes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* CHANGELOG: fold the node-survival fixes into 0.3.0 (rebuilt in place)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: p1neappleXpress <a@a.a>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:28:07 +03:00

1232 lines
45 KiB
Go

package main
import (
"flag"
"fmt"
"log"
"net"
"os"
"runtime"
godebug "runtime/debug"
"strconv"
"strings"
"time"
"github.com/p1neappleXpress/OpenFlux/netbind"
"github.com/p1neappleXpress/OpenFlux/socks5"
"github.com/p1neappleXpress/OpenFlux/streamproxy"
"github.com/p1neappleXpress/OpenFlux/transport"
"github.com/p1neappleXpress/OpenFlux/transport/control"
"github.com/p1neappleXpress/OpenFlux/transport/cupsonline"
"github.com/p1neappleXpress/OpenFlux/transport/ipc"
"github.com/p1neappleXpress/OpenFlux/transport/mailru"
"github.com/p1neappleXpress/OpenFlux/transport/manager"
"github.com/p1neappleXpress/OpenFlux/transport/oneme"
"github.com/p1neappleXpress/OpenFlux/transport/phpbox"
"github.com/p1neappleXpress/OpenFlux/transport/yandex"
"github.com/p1neappleXpress/OpenFlux/tunnel"
"github.com/p1neappleXpress/OpenFlux/utils"
)
var (
globalDocUrl string
maxToken string
maxUid string
localIP string
)
// expandShortFlags rewrites single-letter flag aliases into their long
// forms so both -r and --role work. Handles bare flags (-d) and inline
// values (-r=exit, -u=https://...).
func expandShortFlags(args []string) []string {
aliases := map[string]string{
"-r": "--role",
"-i": "--inbound",
"-t": "--transport",
"-m": "--mode",
"-c": "--codec",
"-u": "--url",
"-s": "--socks5",
"-l": "--local-ip",
}
out := make([]string, 0, len(args))
for i, a := range args {
// Counted debug flag: -d, -dd, -ddd -> --debug=1..3. Only a run of
// d's counts, so single-dash long flags like -direct-listen pass.
if n := debugCount(a); n > 0 {
out = append(out, fmt.Sprintf("--debug=%d", min(n, utils.LevelHexdump)))
continue
}
if strings.HasPrefix(a, "-d=") {
out = append(out, "--debug="+a[len("-d="):])
continue
}
// A bare --debug means -d, unless its level follows ("--debug 2").
if a == "--debug" || a == "-debug" {
if i+1 >= len(args) || !isNumber(args[i+1]) {
out = append(out, "--debug=1")
continue
}
}
replaced := false
for short, long := range aliases {
if a == short {
out = append(out, long)
replaced = true
break
}
if strings.HasPrefix(a, short+"=") {
out = append(out, long+a[len(short):])
replaced = true
break
}
}
if !replaced {
out = append(out, a)
}
}
return out
}
const (
roleClient = "client"
roleExit = "exit"
roleBenchSend = "bench-send"
roleBenchSink = "bench-sink"
)
const (
inboundTUN = "tun"
inboundSOCKS5 = "socks5"
)
const (
codecBatched = "batched"
codecLegacy = "legacy"
)
// transportHasCookies reports whether the given transport uses HTTP cookies
// that can be refreshed via the NegotiatedTransport control channel.
func transportHasCookies(t string) bool {
switch t {
case "yandex", "vyandex", "boards", "mailru", "cupsonline":
return true
}
return false
}
// cookieKey identifies a session inside the cookie store. For most transports
// this is the document URL; for oneme it would be maxUid, but oneme does not
// use the store at all.
func cookieKey(transportType, docURL, maxUid string) string {
switch transportType {
case "oneme":
return maxUid
default:
return docURL
}
}
// debugCount returns how many d's make up a -d, -dd, -ddd flag, or 0.
func debugCount(a string) int {
if len(a) < 2 || a[0] != '-' || strings.Trim(a[1:], "d") != "" {
return 0
}
return len(a) - 1
}
func isNumber(s string) bool {
_, err := strconv.Atoi(s)
return err == nil
}
// contextSources describes the carriers for transport.KDFContexts, the one
// rule every peer (CLI, Android, Desktop, iOS) derives the encryption
// context with.
func contextSources(specs []transportSpec) []transport.ContextSource {
out := make([]transport.ContextSource, len(specs))
for i, s := range specs {
out[i] = transport.ContextSource{Type: s.Type, URL: s.URL, Priority: s.Priority}
}
return out
}
// managerRefreshLoop periodically asks the exit node for a fresh cookie jar.
// Runs on the client side only, when --transports or --negotiate is set.
func managerRefreshLoop(m *manager.Manager) {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if !m.IsConnected() {
continue
}
if err := m.SendControl(control.SubtypeCookiesRequest, nil); err != nil {
utils.Debugf("[CTRL] cookies request failed: %v", err)
continue
}
utils.Debugf("[CTRL] cookies request sent")
}
}
func main() {
// The desktop wizard's JSON protocol owns stdout: no banner, no flags.
if len(os.Args) == 2 && os.Args[1] == "--node-wizard" {
os.Exit(runNodeWizard(os.Stdin, os.Stdout))
}
// The core's own openflux:// parser and builder for apps and scripts,
// so a link is read and made the same way everywhere: JSON on stdout,
// before any banner.
if len(os.Args) == 3 && os.Args[1] == "--parse-link" {
os.Exit(runParseLink(os.Args[2], os.Stdin, os.Stdout))
}
if len(os.Args) == 3 && os.Args[1] == "--make-link" {
os.Exit(runMakeLink(os.Args[2], os.Stdin, os.Stdout))
}
fmt.Print("written by p1neappleXpress\n")
role := flag.String("role", roleClient, "client | exit | bench-send | bench-sink")
inbound := flag.String("inbound", "", "tun | socks5 (client only; default: tun on macOS, socks5 elsewhere)")
transportType := flag.String("transport", "yandex", "Transport type (yandex, vyandex, oneme, cupsonline, mailru)")
mode := flag.String("mode", "", "Exit-node mode: l3 (default; Linux as root, or Windows as Administrator with WinDivert) or l4 (works everywhere)")
codec := flag.String("codec", codecBatched, "batched (default, zstd+coalescing) or legacy (per-packet LZ4)")
negotiate := flag.Bool("negotiate", false, "Require encrypted, session-bound IPv4 capability negotiation on both peers (no legacy fallback)")
maxPacket := flag.Int("max-packet-size", transport.MaxNegotiatedPacket, "Maximum IPv4 packet in negotiated mode (1280..65000); not the Internet path MTU")
cookieStorePath := flag.String("cookie-store", "",
"Path to the cookie jar file. Default: ./cookies-<transport>.json in the current directory. "+
"Ignored for transports without cookies (direct, oneme).")
encryptionKeyFile := flag.String("encryption-key-file", "",
"Optional: encrypt the transport with AES-256-GCM using a shared secret read from this file. "+
"Both peers must use the same secret; unset means unencrypted, unchanged behavior")
sessionContextFlag := flag.String("session-context", "",
"Explicit KDF context for --encryption-key-file. Both peers must use the same value. "+
"Default: derived from the document URL (--url, any --<type>-url, or [Transport] URL "+
"from --config), falling back to --transport. Only set this to override that derivation.")
flag.StringVar(&globalDocUrl, "url", "http://#", "Document URL. If u use Yandex.Docs transport")
flag.StringVar(&maxToken, "maxToken", "", "MAX Web token. If u use MAX transport")
flag.StringVar(&maxUid, "maxUid", "", "MAX call user id. If u use MAX transport")
directDial := flag.String("direct-dial", "", "DirectTransport: exit address to dial (client). Requires --encryption-key-file")
directListen := flag.String("direct-listen", "", "DirectTransport: local address to listen on (exit). Requires --encryption-key-file")
transportsFlag := flag.String("transports", "",
"Comma-separated list of transports with priorities, e.g. "+
"\"direct:100,yandex:50\". If empty, --transport is used as a single transport.")
yandexURL := flag.String("yandex-url", "", "URL for the yandex transport (overrides --url in --transports mode)")
vyandexURL := flag.String("vyandex-url", "", "URL for the vyandex transport")
flag.StringVar(&yandexCookiesFile, "yandex-cookies-file", "", "Netscape cookies.txt with a Yandex login for vyandex transports")
boardsURL := flag.String("boards-url", "", "URL for the boards transport")
mailruURL := flag.String("mailru-url", "", "URL (weblink) for the mailru transport")
cupsonlineURL := flag.String("cupsonline-url", "", "URL for the cupsonline transport")
onemeToken := flag.String("oneme-token", "", "MAX token for the oneme transport")
onemeUID := flag.String("oneme-uid", "", "MAX uid for the oneme transport")
configPath := flag.String("config", "",
"Path to an OpenFlux .conf file. Command-line flags override values from the file.")
shareFlag := flag.Bool("share", false,
"Exit: print an openflux:// link and QR code that clients scan to connect (contains the encryption key)")
shareHost := flag.String("share-host", "",
"Exit: address clients dial for direct in the --share link. Default: this host's first public IPv4")
ipcSocketPath := flag.String("ipc-socket", "",
"Path to the Unix domain socket used by the mobile app to talk to the core. "+
"Empty = no IPC server.")
socksAddr := flag.String("socks5", ":1080", "SOCKS5 address")
httpProxyAddr := flag.String("http-proxy", "", "Client: also serve an HTTP proxy (CONNECT and plain requests) on this address, through the same tunnel")
flag.StringVar(&localIP, "local-ip", "", "Egress IP for exit node (l3 mode only, scoped RST drop)")
benchBytes := flag.Int("bench-bytes", 0, "Benchmark: push this many MB through the transport, then report and exit")
benchCompressible := flag.Bool("bench-compressible", false, "Benchmark: use compressible payload instead of random")
debug := flag.Int("debug", 0, "Debug level: 1 packets (-d), 2 operational logs (-dd), 3 hexdumps (-ddd)")
sensitive := flag.Bool("sensitive", false, "Also log key material and, with -ddd, plaintext frames (cookie jars, tokens)")
sensitiveAlias := flag.Bool("sensetive", false, "Alias for --sensitive")
// Deprecated aliases, kept for one release to ease migration.
depClient := flag.Bool("client", false, "DEPRECATED: use --role=client")
depExit := flag.Bool("exit-node", false, "DEPRECATED: use --role=exit")
depTun := flag.Bool("tun", false, "DEPRECATED: use --inbound=tun")
depSocks5Mode := flag.Bool("socks5-mode", false, "DEPRECATED: use --inbound=socks5")
depLegacy := flag.Bool("legacy", false, "DEPRECATED: use --codec=legacy")
depBenchSend := flag.Int("bench-send", 0, "DEPRECATED: use --role=bench-send --bench-bytes=N")
depBenchSink := flag.Bool("bench-sink", false, "DEPRECATED: use --role=bench-sink")
// Override the default flag.PrintDefaults so -h prints a structured
// usage message with axes, modifiers, and examples instead of a flat
// alphabetical list.
flag.Usage = func() {
fmt.Fprintf(os.Stderr, `OpenFlux — Network stack research tool. TCP tunnel with pluggable transports.
USAGE
openflux --role=<role> --transport=<type> [OPTIONS]
openflux --role=<role> --transports=<list> [OPTIONS]
openflux --config=/path/to/openflux.conf [OPTIONS]
ROLE
-r, --role=client Run as client. (default)
-r, --role=exit Run as exit node.
-r, --role=bench-send Benchmark: push --bench-bytes MB.
-r, --role=bench-sink Benchmark: receive from transport.
TRANSPORT (single-transport mode)
-t, --transport=yandex Yandex.Docs over WebSocket. (default)
-t, --transport=vyandex Yandex.Volga over HTTP relay + WS.
-t, --transport=oneme MAX (VK) over WebRTC.
-t, --transport=cupsonline Cups.online interview rooms.
-t, --transport=mailru Mail.ru Docs over WebSocket.
-t, --transport=direct Plain TCP to a self-hosted exit.
-u, --url=<URL> Document URL.
TRANSPORTS (multi-transport session; requires --encryption-key-file)
--transports=direct:100,yandex:50
Comma-separated list of transports with
priorities. Higher priority = tried first
for the handshake and for control traffic.
All listed transports are attached to one
Session; IPv4 flows are hashed across them.
--yandex-url=<URL> URL for the yandex transport.
--vyandex-url=<URL> URL for the vyandex transport.
--yandex-cookies-file=<path>
Netscape cookies.txt with a Yandex login for
vyandex transports.
--boards-url=<URL> URL for the boards transport.
--mailru-url=<WEBLINK> Weblink for the mailru transport.
--cupsonline-url=<URL> URL for the cupsonline transport.
--oneme-token=<token> MAX auth token for the oneme transport.
--oneme-uid=<uid> MAX user id for the oneme transport.
--direct-dial=<addr> DirectTransport: exit host:port (client).
--direct-listen=<addr> DirectTransport: listen addr on exit.
INBOUND (only with --role=client)
-i, --inbound=tun utun (macOS) / Wintun (Windows, needs administrator
and wintun.dll next to the binary) / NEPacketTunnel
(iOS). Default on macOS.
-i, --inbound=socks5 SOCKS5 + gVisor. Default on other platforms.
-s, --socks5=<addr> SOCKS5 listen address (default :1080).
--http-proxy=<addr> Also serve an HTTP proxy (CONNECT and plain
requests) on this address, e.g. for a system
proxy that only speaks HTTP.
MODE (only with --role=exit)
-m, --mode=l3 Packet forwarding (SNAT/DNAT). Default. Linux
as root; Windows as Administrator with
WinDivert.dll + WinDivert64.sys beside the core.
-m, --mode=l4 Stream proxy (TCP termination + re-dial).
-l, --local-ip=<ip> Egress IP for SNAT. Auto-detected.
TRANSPORT MODIFIERS
-c, --codec=batched zstd + coalescing. Default.
-c, --codec=legacy Per-packet LZ4. A/B only.
--encryption-key-file=<path>
AES-256-GCM wrapper. Required with
--transports or --negotiate. Both peers
must share the same key.
--session-context=<str> Explicit KDF context for that key. Both peers
must use the same value. Default: --url, else
the URL of the highest-priority transport,
else "http://#".
--negotiate Require authenticated capability negotiation
on both peers. No legacy fallback.
--max-packet-size=N Max IPv4 packet in negotiated mode
(1280..65000). Default 65000.
--cookie-store=<path> Cookie jar file. Default: ./cookies-<transport>.json.
--ipc-socket=<path> Unix domain socket for the mobile bridge.
Empty = no IPC server.
--share Exit: print an openflux:// link and QR code for
clients to scan. Contains the encryption key.
--share-host=<host> Exit: address clients dial for direct in the
link. Default: this host's first public IPv4.
--config=<path> Load settings from an OpenFlux .conf file
(INI-like, similar to wg-quick). Command-line
flags override values from the file.
BENCHMARK (only with --role=bench-*)
--bench-bytes=<MB> MB to push (bench-send).
--bench-compressible Repetitive payload (bench-send).
LOGGING
-d, --debug=1 Packet movement: one line per IPv4 packet,
"-> 52 bytes - UDP 10.10.10.2:53000 -> 8.8.8.8:53 ...".
-dd, --debug=2 Plus operational logs: sessions, carriers,
handshakes, crypto, control, errors.
-ddd, --debug=3 Plus hexdumps of packets and ciphertext.
--sensitive Also log key material and, with -ddd, the
plaintext frames (control messages carry
cookie jars and tokens). Off by default.
DEPRECATED (removed in v2)
-client, -exit-node -> --role=client|exit
-tun, -socks5-mode -> --inbound=tun|socks5
-legacy -> --codec=legacy
-bench-send, -bench-sink -> --role=bench-send|bench-sink
`)
}
os.Args = expandShortFlags(os.Args)
flag.Parse()
// isExit is the session/encryption-directionality role: which side of a
// negotiated or encrypted pair this process plays. bench-sink is the
// passive, always-listening side (like an exit), bench-send the active
// initiator (like a client) - without this, two bench processes both
// resolved to "client", so a negotiated session never had an exit side
// to answer hellos with a challenge, and two peers' encryption keys
// were derived in the same direction instead of swapped.
isExit := *role == roleExit || *role == roleBenchSink
// Apply .conf file if requested. Only flags that were not explicitly set
// on the command line are overridden.
//
// confTransports is declared at function scope (not inside the if) so
// pickSessionContext can see it below: a .conf-only deployment has no
// --url/--yandex-url and its document URL lives in the [Transport]
// sections, which must still contribute to the KDF context.
var confTransports []transportSpec
if *configPath != "" {
conf, err := parseConf(*configPath)
if err != nil {
log.Fatalf("--config: %v", err)
}
setFlags := make(map[string]bool)
flag.Visit(func(f *flag.Flag) { setFlags[f.Name] = true })
applyConfString(conf.Interface, "Role", "role", role, setFlags)
// Role may have just changed: isExit above was computed from the
// pre-.conf value, so a config-only "Role = exit" (no --role on the
// command line, as every node-wizard deployment runs) left isExit
// stuck at false. Recompute before it's used below.
isExit = *role == roleExit || *role == roleBenchSink
applyConfString(conf.Interface, "Inbound", "inbound", inbound, setFlags)
applyConfString(conf.Interface, "Transport", "transport", transportType, setFlags)
applyConfString(conf.Interface, "Mode", "mode", mode, setFlags)
applyConfString(conf.Interface, "Codec", "codec", codec, setFlags)
applyConfString(conf.Interface, "Socks5", "socks5", socksAddr, setFlags)
applyConfString(conf.Interface, "EncryptionKeyFile", "encryption-key-file", encryptionKeyFile, setFlags)
applyConfString(conf.Interface, "SessionContext", "session-context", sessionContextFlag, setFlags)
applyConfString(conf.Interface, "CookieStore", "cookie-store", cookieStorePath, setFlags)
applyConfString(conf.Interface, "IPCSocket", "ipc-socket", ipcSocketPath, setFlags)
applyConfString(conf.Interface, "URL", "url", &globalDocUrl, setFlags)
if v, ok := confValue(conf.Interface, "Debug"); ok && !setFlags["debug"] {
if b, err := strconv.Atoi(v); err == nil {
*debug = b
} else if confBool(v, false) {
*debug = 1
}
}
if v, ok := confValue(conf.Interface, "Sensitive"); ok && !setFlags["sensitive"] && !setFlags["sensetive"] {
*sensitive = confBool(v, *sensitive)
}
for _, t := range conf.Transports {
if t.Name == "" {
continue
}
spec := transportSpec{
Name: t.Name,
Type: t.Values["Type"],
Priority: confInt(t.Values["Priority"], 50),
URL: t.Values["URL"],
}
if spec.Type == "" {
spec.Type = t.Name
}
if spec.Type == "direct" {
spec.Params = map[string]interface{}{
"dial": t.Values["Dial"],
"listen": t.Values["Listen"],
"is_exit": isExit,
}
}
if spec.Type == "oneme" {
spec.Params = map[string]interface{}{
"token": t.Values["Token"],
"uid": t.Values["UID"],
"exit": isExit,
}
}
confTransports = append(confTransports, spec)
}
}
// Map deprecated flags to their new counterparts. New flags win over
// deprecated ones if both are supplied.
roleSet := false
flag.Visit(func(f *flag.Flag) {
if f.Name == "role" {
roleSet = true
}
})
if !roleSet {
if *depClient {
log.Printf("warning: -client is deprecated, use --role=client")
*role = roleClient
}
if *depExit {
log.Printf("warning: -exit-node is deprecated, use --role=exit")
*role = roleExit
}
}
if *depTun {
log.Printf("warning: -tun is deprecated, use --inbound=tun")
*inbound = inboundTUN
}
if *depSocks5Mode {
log.Printf("warning: -socks5-mode is deprecated, use --inbound=socks5")
*inbound = inboundSOCKS5
}
if *depLegacy {
log.Printf("warning: -legacy is deprecated, use --codec=legacy")
*codec = codecLegacy
}
if *depBenchSend > 0 {
log.Printf("warning: -bench-send is deprecated, use --role=bench-send --bench-bytes=N")
*role = roleBenchSend
*benchBytes = *depBenchSend
}
if *depBenchSink {
log.Printf("warning: -bench-sink is deprecated, use --role=bench-sink")
*role = roleBenchSink
}
// Platform defaults. The recommended client path is utun on macOS and
// SOCKS5 everywhere else (see README for details). Stream mode keeps
// SOCKS5 unless the full tunnel is asked for by name: it was a proxy
// first, and the apps' proxy profiles do not pass --inbound.
inboundChosen := *inbound != ""
if *inbound == "" {
if runtime.GOOS == "darwin" {
*inbound = inboundTUN
} else {
*inbound = inboundSOCKS5
}
}
if *mode == "" {
*mode = "l3"
}
if *codec != codecBatched && *codec != codecLegacy {
log.Fatalf("--codec: unknown value %q (want batched|legacy)", *codec)
}
switch *role {
case roleClient:
if *inbound != inboundTUN && *inbound != inboundSOCKS5 {
log.Fatalf("--role=client: unknown --inbound=%q (want tun|socks5)", *inbound)
}
case roleExit:
if *mode != "l3" && *mode != "l4" {
log.Fatalf("--role=exit: unknown --mode=%q (want l3|l4)", *mode)
}
case roleBenchSend, roleBenchSink:
// No ingress or exit mode.
default:
log.Fatalf("unknown --role=%q (want client|exit|bench-send|bench-sink)", *role)
}
// Windows full tunnel: bind the core's sockets to the real interface
// before any transport dials, so the carriers stay out of the tunnel
// once it takes the default route (see package netbind).
if *role == roleClient && *inbound == inboundTUN && runtime.GOOS == "windows" {
index, err := netbind.BindDefault()
if err != nil {
log.Fatalf("tun: %v", err)
}
log.Printf("core sockets bound to interface %d", index)
}
// Warn when the exit runs on l4 (gVisor): it works everywhere but is
// slower than l3 (SNAT/DNAT: Linux as root, Windows with WinDivert).
if *role == roleExit && *mode == "l4" {
log.Printf("warning: exit on l4 (gVisor). l3 is faster: Linux as root, Windows as Administrator.")
}
// --mode=stream: the client speaks the phpbox stream mux (OPEN/DATA/CLOSE
// frames) over the transport instead of IP packets through gVisor, and a
// local SOCKS5 hands each app connection to a mux stream. The exit is a
// phpbox exit (deploy/phpbox over cups). This is a circuit-level TCP
// tunnel, not L7 - the exit never parses the application protocol.
// The debug level is set here, not only further down: this branch returns before that code runs.
if *role == roleClient && *mode == "stream" {
utils.SetLevel(*debug)
if *sensitive || *sensitiveAlias {
utils.SetSensitive(true)
}
if *inbound == inboundTUN && inboundChosen {
runStreamTUN(*transportType, globalDocUrl)
return
}
runStreamClient(*transportType, globalDocUrl, *socksAddr, *httpProxyAddr, *ipcSocketPath)
return
}
exitMode, err := tunnel.ParseExitMode(*mode)
if err != nil {
log.Fatalf("--mode: %v", err)
}
// The exit node often runs on a tiny VPS; keep the heap tight under load
// (GC aggressively). Set GOMEMLIMIT in the environment for a hard soft-cap.
if *role == roleExit {
godebug.SetGCPercent(20)
}
utils.SetLevel(*debug)
if *sensitive || *sensitiveAlias {
utils.SetSensitive(true)
}
utils.Debugf("[INIT] debug level=%d sensitive=%v", utils.Level(), utils.Sensitive())
log.Printf("=== OpenFlux ===")
log.Printf("Role: %s", *role)
// A .conf's [Transport ...] sections or --transports run a Session of
// several carriers; *transportType stays at its flag default ("yandex")
// in both cases, so printing it unconditionally here always claimed
// "yandex" regardless of what was actually configured.
switch {
case len(confTransports) > 0:
names := make([]string, len(confTransports))
for i, t := range confTransports {
names[i] = t.Type
}
log.Printf("Transport: %s (session)", strings.Join(names, ", "))
case *transportsFlag != "":
log.Printf("Transport: %s (session)", *transportsFlag)
default:
log.Printf("Transport: %s", *transportType)
}
if *role == roleClient {
log.Printf("Inbound: %s", *inbound)
}
if *role == roleExit {
log.Printf("Exit mode: %s", exitMode.String())
}
config := transport.DefaultConfig()
// Cookie store: per-transport file in pwd, unless --cookie-store is set.
// Transports without cookies (direct, oneme) skip it entirely.
var store *transport.CookieStore
if transportHasCookies(*transportType) {
path := *cookieStorePath
if path == "" {
path = fmt.Sprintf("./cookies-%s.json", *transportType)
}
s, err := transport.NewCookieStore(path)
if err != nil {
log.Fatalf("Cookie store %s: %v", path, err)
}
store = s
log.Printf("Cookie store: %s", path)
}
// Build the list of transports to run. Two modes:
// --transports=direct:100,yandex:50 -> multi-transport session
// --transport=<type> -> legacy single-transport mode
var specs []transportSpec
// Running transports whose client address (cupsonline rooms) exists only
// once they start, by spec name, for --share.
rooms := make(map[string]roomLister)
if len(confTransports) > 0 {
specs = confTransports
// Per-type URL flags still override config values.
urls := map[string]string{
"yandex": *yandexURL,
"vyandex": *vyandexURL,
"boards": *boardsURL,
"mailru": *mailruURL,
"cupsonline": *cupsonlineURL,
}
specs = buildTransportSpecs(specs, urls, nil)
} else if *transportsFlag != "" {
parsed, err := parseTransportList(*transportsFlag)
if err != nil {
log.Fatalf("--transports: %v", err)
}
urls := map[string]string{
"yandex": *yandexURL,
"vyandex": *vyandexURL,
"boards": *boardsURL,
"mailru": *mailruURL,
"cupsonline": *cupsonlineURL,
}
if globalDocUrl != "" && globalDocUrl != "http://#" && urls["yandex"] == "" {
urls["yandex"] = globalDocUrl
}
extra := map[string]map[string]interface{}{
"oneme": {"token": *onemeToken, "uid": *onemeUID, "exit": isExit},
"direct": {
"dial": *directDial,
"listen": *directListen,
"is_exit": isExit,
},
}
specs = buildTransportSpecs(parsed, urls, extra)
} else {
// Named after the type, as --transports and the apps name
// carriers: cookie exchange with a Session peer is by name.
specs = []transportSpec{{
Name: *transportType,
Type: *transportType,
Priority: 100,
URL: globalDocUrl,
}}
if *transportType == "oneme" {
specs[0].Params = map[string]interface{}{
"token": maxToken, "uid": maxUid, "exit": isExit,
}
}
if *transportType == "direct" {
specs[0].Params = map[string]interface{}{
"dial": *directDial, "listen": *directListen, "is_exit": isExit,
}
}
}
// Validate --codec with the multi-transport path. Session always uses
// BatchedTransport, so --codec=legacy is only valid in single-transport
// non-negotiated mode.
if *negotiate && *codec != codecBatched {
log.Fatal("--negotiate requires --codec=batched")
}
// Encryption secret is mandatory when --negotiate is set.
//
// The session context is the KDF salt for the encryption keys and MUST
// be identical on both peers; see pickSessionContext.
var secret string
var sessionContext string
if *encryptionKeyFile != "" {
b, err := os.ReadFile(*encryptionKeyFile)
if err != nil {
log.Fatalf("Read encryption key file: %v", err)
}
secret = strings.TrimSpace(string(b))
if n := utils.SecretChars(secret); n < utils.MinSecretChars {
log.Fatalf("Encryption key from %s is too short (%d chars, need at least %d)",
*encryptionKeyFile, n, utils.MinSecretChars)
}
if strings.ContainsAny(secret, "\r\n\t") {
utils.Debugf("[KEY] WARNING: secret still contains whitespace after TrimSpace; lengths may differ across platforms")
}
// No hash of the secret without --sensitive: it would let anyone
// with the log test guesses without paying for scrypt.
utils.Debugf("[KEY] loaded from %s: len=%d", *encryptionKeyFile, len(secret))
if utils.Sensitive() {
utils.Debugf("[KEY] secret sha256=%s", utils.Sha256Hex([]byte(secret)))
}
}
sessionContext, contextAlternates := transport.KDFContexts(*sessionContextFlag, globalDocUrl, contextSources(specs))
if *encryptionKeyFile != "" {
utils.Debugf("[KEY] context=%q sha256=%s (MUST match on both peers; %d alternates tried on mismatch)",
sessionContext, utils.Sha256Hex([]byte(sessionContext)), len(contextAlternates))
log.Printf("Encryption context: sha256 %s", utils.Sha256Short([]byte(sessionContext)))
}
// Decide whether we run the full Session path (encryption + negotiate)
// or the legacy single-transport path.
var (
managerInst *manager.Manager
trans transport.Transport
exchanger transport.CookieExchanger
demux *transport.PortDemux
)
// configuredSession: the operator asked for a Session. [Transport]
// sections in a .conf describe one just like --transports.
//
// A classic setup (--transport=X) with a key runs as a Session too,
// with classic compatibility: a client falls back to the classic
// layering while the exit does not answer the handshake and upgrades
// once it does; an exit serves classic clients and Session clients.
// Only --negotiate is strict. Without a key only classic is possible.
configuredSession := *negotiate || *transportsFlag != "" || len(confTransports) > 0
classicCompat := false
switch {
case *role != roleClient && *role != roleExit:
case !configuredSession && secret != "":
classicCompat = true
case configuredSession && !*negotiate && *role == roleClient && len(specs) == 1:
classicCompat = true
}
if configuredSession || classicCompat {
if secret == "" {
log.Fatal("--transports/--negotiate/.conf transports require --encryption-key-file")
}
switch {
case classicCompat && isExit:
log.Printf("Mode: Session, also serving classic clients (--negotiate makes it Session-only)")
case classicCompat:
log.Printf("Mode: Session, falling back to classic while the exit does not answer the handshake")
default:
log.Printf("Mode: Session")
}
caps := transport.CapabilityIPv4 | transport.CapabilityTCP | transport.CapabilityUDP
if *role == roleClient || exitMode == tunnel.ExitModeL3 {
caps |= transport.CapabilityICMPErrors
}
params := transport.PeerParameters{
Capabilities: caps,
MaxPacketSize: *maxPacket,
}
sess, err := transport.NewSession(params, isExit)
if err != nil {
log.Fatal(err)
}
if classicCompat {
sess.SetClassic(*codec)
}
sess.SetAlternateContexts(contextAlternates)
// Build the factory that SubtypeTransportStart will use for
// dynamic transports.
factory := transportFactory(config, isExit)
managerInst = manager.New(sess, factory, secret, sessionContext)
if err := registerBootstrapTransports(managerInst, specs, config, secret, sessionContext, rooms); err != nil {
log.Fatalf("bootstrap transports: %v", err)
}
// Persist each cookie-carrying transport's jar and replay what was
// saved; the Manager routes cookie control messages by name.
if store != nil {
for _, spec := range specs {
if !transportHasCookies(spec.Type) {
continue
}
if err := managerInst.UseCookieStore(store, spec.Name, cookieKey(spec.Type, spec.URL, maxUid)); err != nil {
utils.Debugf("[COOKIE] replay %s: %v", spec.Name, err)
}
}
}
// Hook the Session control dispatcher into the manager.
sess.SetControlHandler(managerInst.DispatchControl)
// Optional IPC bridge: if --ipc-socket is set, the core talks to the
// mobile app over a Unix domain socket. Transport-initiated captcha
// requests go out as MsgCookiesRequest; cookies offers come in as
// MsgCookiesOffer.
if *ipcSocketPath != "" {
h := &coreIPCHandler{manager: managerInst}
srv := ipc.NewServer(*ipcSocketPath, h)
if err := srv.Listen(); err != nil {
log.Fatalf("IPC listen %s: %v", *ipcSocketPath, err)
}
defer srv.Close()
statusServer = srv
// Checks for local transports go to the app as-is; checks the
// exit reports are marked Remote, to be passed from its address.
managerInst.SetCaptchaNotifier(func(name, url, reason string) {
_ = srv.SendCookiesRequest(&ipc.CookiesRequestPayload{
Transport: name, URL: url, Reason: reason,
})
})
if *role == roleClient {
demux = transport.NewPortDemux(managerInst, authProxyPortLo, authProxyPortHi)
authProxy := &remoteAuthProxy{demux: demux}
managerInst.SetRemoteAuthNotifier(func(name, url, reason string) {
proxy, err := authProxy.Addr()
if err != nil {
log.Printf("remote auth proxy: %v", err)
}
_ = srv.SendCookiesRequest(&ipc.CookiesRequestPayload{
Transport: name, URL: url, Reason: reason, Remote: true, Proxy: proxy,
})
})
}
}
trans = managerInst
if demux != nil {
trans = demux
}
exchanger = nil // cookie handling lives in the Manager
} else {
// Classic single-transport path without a Session: no key (the
// Session needs one), or a bench role.
var inner transport.Transport
switch *transportType {
case "boards":
inner = yandex.NewBoardsTransport(globalDocUrl, config)
case "vyandex":
t, err := newVolgaTransport(globalDocUrl, config)
if err != nil {
log.Fatalf("vyandex: %v", err)
}
inner = t
case "yandex":
inner = yandex.NewYandexDocsTransport(globalDocUrl, config)
case "oneme":
uidint, _ := strconv.ParseInt(maxUid, 10, 64)
inner = oneme.NewOneMeTransport(isExit, maxToken, uidint, config)
case "cupsonline":
c := cupsonline.NewCupsonlineTransport(globalDocUrl, config, !isExit)
rooms[specs[0].Name] = c
inner = c
case "mailru":
inner = mailru.NewMailruDocsTransport(globalDocUrl, config)
default:
log.Fatalf("Unknown transport type: %s", *transportType)
}
// Persist cookie exchanger for the legacy path.
if store != nil {
if ce, ok := inner.(transport.CookieExchanger); ok {
key := cookieKey(*transportType, globalDocUrl, maxUid)
if jar := store.Load(key); jar != nil {
_ = ce.ApplyCookies(jar)
}
exchanger = transport.NewPersistentCookieExchanger(ce, store, key)
}
}
// Either framing is accepted; the preferred one is sent until the
// peer shows which it speaks (see transport.CodecTransport).
log.Printf("Codec: %s preferred, falls back to the other framing when the peer does not answer", *codec)
inner = transport.NewCodecTransport(inner, *codec, !isExit)
if *encryptionKeyFile != "" {
encrypted, err := transport.NewEncryptedTransport(inner, secret, sessionContext, isExit)
if err != nil {
log.Fatalf("Configure encrypted transport: %v", err)
}
encrypted.SetAlternateContexts(contextAlternates)
inner = encrypted
log.Printf("Transport encryption: AES-256-GCM enabled")
} else {
log.Printf("Transport encryption: OFF (no --encryption-key-file): the carrier sees the traffic, and a peer with a key cannot talk to this one")
}
trans = inner
// The app's IPC bridge works here too: traffic totals for its speed
// counters, and cookies it offers go to the carrier.
if *ipcSocketPath != "" {
srv := ipc.NewServer(*ipcSocketPath, &coreIPCHandler{exchanger: exchanger})
if err := srv.Listen(); err != nil {
log.Fatalf("IPC listen %s: %v", *ipcSocketPath, err)
}
defer srv.Close()
statusServer = srv
}
}
_ = exchanger
_ = managerInst
// Benchmark modes run the transport directly with no tunnel / raw socket,
// so they never touch the host network.
if *role == roleBenchSend {
if *benchBytes <= 0 {
log.Fatalf("--role=bench-send requires --bench-bytes=<MB>")
}
runBenchSend(trans, *benchBytes, *benchCompressible)
return
}
if *role == roleBenchSink {
runBenchSink(trans)
return
}
if err := trans.Start(); err != nil {
log.Fatalf("Failed to start transport: %v", err)
}
if statusServer != nil {
if managerInst != nil {
utils.SafeGo("ipc-status", func() {
ipcStatusLoop(statusServer, managerInst, managerInst.Session().ActiveTransport, managerInst.Session().ActiveTransports)
})
} else {
utils.SafeGo("ipc-status", func() { ipcStatusLoop(statusServer, trans, nil, nil) })
}
}
// Periodically ask the exit node to refresh its cookies. Only the client
// initiates; the exit answers with SubtypeCookiesResponse.
if *role == roleClient && managerInst != nil {
utils.SafeGo("cookie-refresh", func() { managerRefreshLoop(managerInst) })
}
if managerInst != nil {
if pp, ready := managerInst.Session().PeerParameters(); ready {
log.Printf("Authenticated peer: IPv4 TCP; UDP=%t; ICMP errors=%t; maximum packet=%d",
pp.Capabilities&transport.CapabilityUDP != 0,
pp.Capabilities&transport.CapabilityICMPErrors != 0,
pp.MaxPacketSize)
}
} else if n, ok := trans.(*transport.Session); ok {
if pp, ready := n.PeerParameters(); ready {
log.Printf("Authenticated peer: IPv4 TCP; UDP=%t; ICMP errors=%t; maximum packet=%d",
pp.Capabilities&transport.CapabilityUDP != 0,
pp.Capabilities&transport.CapabilityICMPErrors != 0,
pp.MaxPacketSize)
}
}
switch *role {
case roleExit:
var printLink func()
if *shareFlag {
// A classic exit keeps advertising classic: older clients
// read the link too, and updated ones upgrade on their own.
session := configuredSession
host := *shareHost
if host == "" {
host = publicIPv4()
}
printLink = func() {
printShare(shareConfig(specs, session, *codec, secret, sessionContext, host, rooms))
}
}
// A cupsonline exit learns its room list only once it runs. Older
// classic clients derived their key from that list; accept it as
// an alternate context. Rooms created later (a start that failed
// and was retried) or anew change the link: print it again.
var sess *transport.Session
if managerInst != nil {
sess = managerInst.Session()
}
for _, r := range rooms {
r.OnRoomList(func(packed string) {
if sess != nil && packed != "" {
sess.SetAlternateContexts([]string{packed})
}
if printLink != nil {
printLink()
}
})
if list := r.RoomList(); list != "" && sess != nil {
sess.SetAlternateContexts([]string{list})
}
}
if printLink != nil {
printLink()
}
runExit(trans, exitMode)
case roleClient:
runClient(trans, *inbound, *socksAddr, *httpProxyAddr, exitMode)
default:
log.Fatalf("unhandled role %q", *role)
}
}
// statusServer is the IPC bridge, set when --ipc-socket is given.
var statusServer *ipc.Server
// statusSource is what the IPC status reports on: a Session's manager or
// a classic carrier without one.
type statusSource interface {
IsConnected() bool
Stats() transport.TransportStats
}
// ipcStatusLoop reports to the app every second: whether a carrier reaches
// the peer, traffic totals, uptime and (Sessions) the active carrier.
func ipcStatusLoop(srv *ipc.Server, src statusSource, active func() string, activeAll func() []string) {
started := time.Now()
tick := time.NewTicker(time.Second)
defer tick.Stop()
for range tick.C {
st := src.Stats()
p := &ipc.StatusPayload{
Running: true,
Connected: src.IsConnected(),
BytesIn: st.BytesReceived,
BytesOut: st.BytesSent,
UptimeMs: time.Since(started).Milliseconds(),
}
if active != nil {
p.Active = active()
}
if activeAll != nil {
p.ActiveAll = activeAll()
}
_ = srv.SendStatus(p)
}
}
func runExit(trans transport.Transport, exitMode tunnel.ExitMode) {
if exitMode == tunnel.ExitModeL3 {
if err := tunnel.SetLocalIP(localIP); err != nil {
log.Fatalf("--local-ip: %v", err)
}
}
ex, err := tunnel.NewExitNode(trans, exitMode.String())
if err != nil {
log.Fatalf("exit node: %v", err)
}
log.Printf("Running as EXIT NODE (mode=%s)", ex.Mode())
if err := ex.Start(); err != nil {
log.Fatalf("exit start: %v", err)
}
// L3 SNAT rewrites source IPs; the kernel sees return packets for
// connections it never opened and emits RST, tearing them down.
// On Linux the operator must drop outbound RSTs matching the egress IP;
// the Windows backend drops them itself, per flow, through WinDivert.
if exitMode == tunnel.ExitModeL3 && runtime.GOOS == "linux" {
if localIP != "" {
log.Printf("! Run: sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -s %s -j DROP", localIP)
} else {
log.Printf("! Kernel RSTs would tear down tunnel connections. Prefer a scoped rule:")
log.Printf("! assign a dedicated alias IP, run with --local-ip <ip>, then:")
log.Printf("! sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -s <ip> -j DROP")
log.Printf("! Host-wide fallback (drops ALL outbound RST; makes closed ports look filtered):")
log.Printf("! sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP")
}
}
select {}
}
// runStreamClient runs the --mode=stream client: a raw transport carries the
// phpbox stream mux, and a local SOCKS5 (and optional HTTP) proxy dials each
// app connection out as a mux stream to the phpbox exit. No gVisor, no IP
// packets. The proxying itself is package streamproxy, shared with the
// mobile bridges.
// streamCarrier builds the carrier the stream mux rides.
func streamCarrier(transportType, url string) phpbox.Carrier {
cfg := transport.DefaultConfig()
switch transportType {
case "cupsonline":
return cupsonline.NewCupsonlineTransport(url, cfg, true)
case "yandex", "":
return yandex.NewYandexDocsTransport(url, cfg)
case "vyandex":
t, err := newVolgaTransport(url, cfg)
if err != nil {
log.Fatalf("--mode=stream vyandex: %v", err)
}
return t
case "mailru":
return mailru.NewMailruDocsTransport(url, cfg)
}
log.Fatalf("--mode=stream: transport %q not supported (use cupsonline, yandex, vyandex, mailru)", transportType)
return nil
}
// runStreamTUN is the stream mode as a full tunnel (--inbound=tun): the
// system's traffic goes into a local stack that opens one mux stream per TCP
// connection (tunnel.StreamNet, which is a transport, so the utun/Wintun
// client runs on it as it does on any other).
func runStreamTUN(transportType, url string) {
sn := tunnel.NewStreamNet(streamCarrier(transportType, url))
if err := sn.Start(); err != nil {
log.Fatalf("--mode=stream: %v", err)
}
log.Printf("Running as CLIENT (stream mux over %s, full tunnel)", transportType)
runClientTUN(sn)
}
func runStreamClient(transportType, url, socksAddr, httpProxyAddr, ipcSocket string) {
carrier := streamCarrier(transportType, url)
p, err := streamproxy.Start(streamproxy.Options{Carrier: carrier, Socks: socksAddr, HTTP: httpProxyAddr, Label: transportType})
if err != nil {
log.Fatalf("--mode=stream: %v", err)
}
defer p.Stop()
// The app's IPC bridge works here too: traffic totals for its speed counters.
if ipcSocket != "" {
var exchanger transport.CookieExchanger
if x, ok := carrier.(transport.CookieExchanger); ok {
exchanger = x
}
srv := ipc.NewServer(ipcSocket, &coreIPCHandler{exchanger: exchanger})
if err := srv.Listen(); err != nil {
log.Fatalf("IPC listen %s: %v", ipcSocket, err)
}
defer srv.Close()
statusServer = srv
go streamStatusLoop(srv, p)
}
if httpProxyAddr != "" {
log.Printf("HTTP proxy on %s", httpProxyAddr)
}
log.Printf("Running as CLIENT (stream mux over %s, SOCKS5 on %s)", transportType, socksAddr)
select {}
}
// streamStatusLoop reports the stream client to the app every second.
func streamStatusLoop(srv *ipc.Server, p *streamproxy.Proxy) {
started := time.Now()
tick := time.NewTicker(time.Second)
defer tick.Stop()
for range tick.C {
_ = srv.SendStatus(&ipc.StatusPayload{
Running: true,
Connected: p.Connected(),
BytesIn: uint64(p.BytesReceived()),
BytesOut: uint64(p.BytesSent()),
UptimeMs: time.Since(started).Milliseconds(),
})
}
}
func runClient(trans transport.Transport, inbound, socksAddr, httpProxyAddr string, exitMode tunnel.ExitMode) {
switch inbound {
case inboundTUN:
runClientTUN(trans)
case inboundSOCKS5:
// Explicit opt-in to the legacy SOCKS5+gVisor client. Kept as a fallback
// for platforms without a tun client (see README).
log.Printf("Running as CLIENT (SOCKS5 on %s, legacy gVisor path)", socksAddr)
tun := tunnel.NewTCPTunnelMode(trans, false, exitMode)
if httpProxyAddr != "" {
ln, err := net.Listen("tcp", httpProxyAddr)
if err != nil {
log.Fatalf("--http-proxy %s: %v", httpProxyAddr, err)
}
log.Printf("HTTP proxy on %s", httpProxyAddr)
utils.SafeGo("http-proxy", func() { _ = tunnel.ServeHTTPProxy(ln, tun.DialTCP) })
}
socks5Server := socks5.NewSOCKS5Server(socksAddr, tun)
log.Fatal(socks5Server.Start())
default:
log.Fatalf("--inbound: unknown value %q (want tun|socks5)", inbound)
}
}
func runClientTUN(trans transport.Transport) {
tc, err := NewTUNClient(trans, 1280)
if err != nil {
log.Fatalf("utun: %v", err)
}
log.Printf("utun interface: %s", tc.Name())
// Save the CURRENT default (which may be another VPN's utun) so
// we can restore it on exit no matter what.
if err := tc.SaveDefault(); err != nil {
log.Fatalf("save default route: %v", err)
}
if err := tc.SetupInterface(); err != nil {
log.Fatalf("setup utun (need sudo): %v", err)
}
log.Printf("utun up; bypass gateway is %s", tc.Gateway())
watcher := NewSocketWatcher(tc.Gateway(), func() {
log.Printf("Socket set stable; taking default route into the tunnel")
if err := tc.ConfigureDefault(); err != nil {
log.Printf("FATAL: configure default: %v", err)
return
}
tc.Start()
log.Printf("Tunnel active")
})
watcher.Start(2 * time.Second)
sigCh := make(chan os.Signal, 1)
notifySignals(sigCh)
<-sigCh
watcher.Stop()
log.Printf("Shutting down, restoring default route...")
if err := tc.Close(); err != nil {
log.Printf("cleanup warning: %v", err)
}
tc.RestoreDefault()
log.Printf("Shutdown complete")
os.Exit(0)
}