Documentation
¶
Overview ¶
Package skysocks pkg/skysocks/accept_stats.go c4-app-proxy
Accounting for the SOCKS accept path — the stage BEFORE a stream is tracked.
The per-stream table (streamSnapshot) only starts at handleStream, which is reached after Accept -> pickSession -> Open -> the SOCKS sniff. A connection that dies earlier leaves no row anywhere, so "the browser got nothing and the stream table is empty" was indistinguishable from "no browser ever connected". Measured in the wasm desk: six concurrent SOCKS dials, three closed with a network error and three never progressed, and the stream table showed zero rows the whole time — which said only that handleStream was never reached, not why.
These counters close that gap: they say how many connections were accepted, how many found no live tunnel to put a stream on, how many failed to open one, and how long the slowest open took.
Package skysocks pkg/skysocks/chunk_plan.go — chunk granularity for a range-split download and for a striped upload (planUploadChunk, at the foot of this file, is the same arithmetic without the probe chunk).
A fixed 4 MiB chunk is right for a 100 MB object and wrong for a 10 MB one: three chunks over two tunnels split 2+1, so one tunnel carries two thirds of the file and the download finishes at that tunnel's rate. Measured live 2026-09-16 (bench/2026-09-16/67dddb8be/mux-compose-T2xL2), two tunnels of two legs each beat the best single route by x1.52 at 100 MB and x1.65 at 50 MB but LOST at 10 MB (x0.79) — the composition's start-up cost, of which granularity is one part.
The plan here sizes a chunk from the object instead: roughly two chunks per active tunnel, floored at 1 MiB (below that the per-chunk prelude dominates) and capped by the configured chunk size, which stays the operator's CEILING (RANGE_CHUNK_KIB / the app's --range-chunk arg), never a fixed step.
Package skysocks pkg/skysocks/client.go c4-app-proxy
Package skysocks pkg/skysocks/client_live_ops.go
The operations a live knob change asks of the tunnels already held.
A value knob (a timeout, a chunk size) needs nothing here: the use site reads it the next time it runs. The three knobs in this file describe a SHAPE — how many tunnels carry streams, how many are held in reserve, whether the shape may drift on its own — so a change to one is only real once the set of tunnels has been moved to match it. That reconcile is a promote and a park, both of which the pool already does for its own reasons; nothing here dials and nothing here waits.
cutTunnel is the fourth: not a shape but a one-off instruction, closing the tunnel an operator named. It is the teardown a bench used to fake by cutting the host's transport, which took every other route over that transport down with it.
Package skysocks pkg/skysocks/client_shape.go
The app half of the mux SHAPE axis (docs/design/mux-shape-axis.md step 6).
A shape is "k tunnels of n legs". The router owns n — it composes and decomposes legs under one route group — and it can flip a standby group's ROLE to active when the shape asks for a wider k. What it cannot do is put a stream on the result: a tunnel is a yamux.Session over a route group, and that session belongs to this process. So k travels the other way, from the router to the app, and the app's active set follows it.
The channel is the one the client already polls: the visor's per-tunnel snapshot (pkg/proxystatus), pulled every tunnel.prior_refresh for the capacity priors, now carries the session's ShapeTunnels and ShapeSource. No new RPC, no new tick, and nothing to send when the shape never moves.
Package skysocks pkg/skysocks/common.go c4-app-proxy
Package skysocks pkg/skysocks/disconnected.go c4-app-proxy
Disconnected-state loopback listener. skysocks-client dials the exit BEFORE it binds :1080 (see cmd/apps/skysocks-client): the live Client is constructed from the yamux session conn, so until the dial succeeds there is no Client and nothing listens on :1080. During that "still connecting / exit route group never came up" window a browser pointed at :1080 got connection-refused — including for the reserved status.skysocks diagnostic host, which is exactly when a user wants to see why the proxy isn't connected.
ServeDisconnected fills that window: it owns :1080 while the dial is in flight and answers each browser SOCKS5 connection LOCALLY. The reserved diagnostic hosts (proxystatus.Match, e.g. status.skysocks) are rendered in-process with zero exit involvement; every real target gets the branded "building a route" interstitial (plaintext HTTP) or is declined (other ports). No real traffic is ever tunneled here — there is no exit to tunnel to — so a listening-but-not-connected :1080 never silently serves or blackholes real requests; it only surfaces the local diagnostic/interstitial pages, which never needed the exit anyway.
Package skysocks pkg/skysocks/listen_unix.go c4-app-proxy
Package skysocks pkg/skysocks/localresolver.go c4-app-proxy
Mesh names answered by the visor's own resolving proxy, over the data plane this app already holds.
Before this, a browser wanting both clearnet and `<pk>.skynet` had to be pointed at a CHAIN of localhost proxies: the resolvers' listeners in front, forwarding what they did not resolve to this client's :1080. Every clearnet request then paid two extra localhost SOCKS handshakes, and the operator configured the same port numbers twice.
The visor publishes each resolving proxy as a local service (see pkg/app/appnet/local_service.go), so this client can keep ONE listener and hand a mesh name straight to the resolver: a dial to its own visor's PK, which the visor answers in-process over net.Pipe. The resolver is still the only thing that resolves; this only removes the hop.
A mesh name needs no exit, which is why the lookup happens before a tunnel is picked: `<pk>.skynet` resolves while the exit is down, and while there is no exit configured at all.
Package skysocks pkg/skysocks/pool_auto.go
Auto-sizing the standby pool (--standby-pool -1, the default).
A fixed ceiling is wrong both ways: on a visor with a handful of transports the fill stops at exhaustion long before it, and on a well-connected one it stops a pool that could hold every disjoint route to the exit. The number that actually bounds the pool is the topology's: how many routes to the exit share no intermediate and no first hop — one per intermediate this visor and the exit both hold a transport to, plus the direct transports. The visor counts that from the route oracle's candidate set and reports it on the proxy-status snapshot the client already polls; the pool follows it, never past pool.size_cap, because every standby tunnel is a route group on the exit too.
Package skysocks pkg/skysocks/rangesplit.go — transparent HTTP range-splitting.
A browser or plain HTTP client pointed at the skysocks SOCKS5 proxy issues an ordinary GET. When the origin is reachable over plaintext HTTP (port 80) and advertises byte ranges (RFC 7233), the client fetches the body as several concurrent byte ranges over SEPARATE exit streams and reassembles it in order into a single 200 response — so one unmodified download spreads across the mesh's tunnels with no client cooperation. Anything that cannot be split (not a GET, an already-ranged request, a non-range origin, a small file) falls back to a byte-identical transparent splice.
HTTPS (port 443) is handled in rangesplit_https.go, but only when the operator has explicitly enabled it: seeing the GET inside TLS requires terminating it at the proxy with a leaf minted by an UNCONSTRAINED local root (the resolver CA is name-constrained to .skynet/.dmsg/.skysocks and cannot cover a real origin). That root can forge any host, so HTTPS range-splitting is off by default and the browser must be told to trust the root; a plain :443 CONNECT splices through unchanged until then.
Package skysocks pkg/skysocks/rangesplit_https.go — HTTPS (TLS-terminating) range-splitting.
The plaintext splitter (rangesplit.go) cannot see the GET inside a :443 CONNECT. This file terminates that TLS at the proxy — presenting the browser a leaf minted on demand by a local UNCONSTRAINED root — reads the plaintext GET, and fetches the body as N concurrent byte ranges over separate exit streams, each wrapped in a fresh TLS client connection to the REAL origin (verified against the system roots, so the proxy never downgrades origin security). The reassembled 200 is written back to the browser over the terminated TLS. Anything not splittable becomes a transparent plaintext MITM splice between the browser TLS conn and one origin TLS conn.
This is a deliberate security decision and therefore OFF by default:
- The minting root can forge a leaf for ANY host, so it is generated explicitly (skynetca.CAOptions.Unconstrained) and the operator must import it into the browser's trust store for the terminated TLS to be accepted.
- The origin side is verified normally; a MITM here interposes on the mesh path the exit already sees, it does not weaken what the origin proves.
Package skysocks pkg/skysocks/server.go c4-app-proxy
Package skysocks pkg/skysocks/settings.go
The client's side of the live settings surface. The registry itself lives in pkg/skysocks/skysettings (import-free, so the CLI can read the catalog); this file is the seam between it and the client:
- the accessors the use sites read, each defaulting to the constant the package compiled with, so an unset client is byte-for-byte today's;
- pullSettings, hung on the keepalive loop's existing RTT tick — the app is the RPC client, so the running app PULLS what the visor holds for it.
The two range-split knobs are OVERRIDES rather than plain values: chunk size and concurrency already have boot flags (--range-chunk-kib, --range-concurrency), so the knob wins only once it has been explicitly set.
Package skysocks pkg/skysocks/spread.go — the spread policy.
The object under a range-split download or a striped upload is a set of independent chunks, and every chunk is placed on one tunnel. Who places them is this file. Today's placement is pickSessionFor(pickRecv): a per-STREAM rule — fewest streams, weighted by proven capacity — which knows nothing about the object those streams belong to. That is why a 3 MB/s tunnel beside an 8 MB/s one drags the pair below the fast tunnel alone: the picker hands each of them an equal number of streams, so the object finishes at the pace of the slow half of it.
spreadPlanner is the per-OBJECT view the picker lacks. It keeps a ledger of the bytes each tunnel has carried for this object and answers "which tunnel next" from it, so the shares converge on the tunnels' measured capacities instead of on their stream counts — and so a share can be CAPPED, which is what criterion 10 asks for and what a privacy-driven spread needs: no single route sees most of an object.
The knobs (docs/design/route-spread-policy.md) all default to OFF. With nothing set the planner still keeps the ledger — that is where the `shares=` line in a completion log comes from — but it leaves the choice to pickSessionFor, so an unset client places chunks byte for byte as it does today.
Package skysocks pkg/skysocks/tunnel_adopt.go
The app half of LEG ADOPTION (router/leg_adopt.go): a chain the router split out of a tunnel into packet-level standby — a LEG RESERVE — made a stream-level tunnel again.
The router can only expose the move, because a tunnel is a yamux session and only this process can run one. So when the active set needs another tunnel the client may, instead of promoting one of its own standbys or dialing a new route, dial NAMING a reserve from the visor's snapshot: the visor re-points the reserve's chain at the new tunnel and handshakes it, and the conn that comes back is added here exactly like a promoted standby. Any failure falls back to what the client did before.
Package skysocks pkg/skysocks/tunnel_depth.go — the per-tunnel queue depth, sized from the bandwidth-delay product.
chunk.per_tunnel (2) and upload.concurrency (4) are FIXED queue depths, chosen when every tunnel was assumed alike. They are not: the campaign rig runs a 40 ms direct tunnel beside a 470 ms Sydney one, and a depth right for the first leaves the second empty for most of every round trip while a depth right for the second buffers megabytes on the first.
The depth that is right for both is bittorrent's pipelining rule: keep a peer with enough outstanding requests to cover one round trip of ITS OWN rate, and no more. That is the bandwidth-delay product measured in chunks — one chunk on a fast near tunnel, four on a slow far one — and the meter already holds both terms, the proven capacity per direction and the smoothed RTT.
It is OFF by default (chunk.depth_dynamic, upload.depth_dynamic), so a client that sets nothing keeps the fixed depths byte for byte. The operator turns it on live and the default moves only once a rig row says it should.
Package skysocks pkg/skysocks/tunnel_liveness.go
What the pool is allowed to call dead.
The hard-dead window on its own is an OBSERVATION of silence, not evidence of death: it fires whenever nothing arrived, including when nothing could arrive because the local dataplane itself was stalled. On 2026-09-18 a ~30 s blackout in the router's inbound loop (a transit peer that stopped draining — see pkg/router/router_forward.go) silenced every tunnel at once, and the pool answered by retiring 3 HEALTHY tunnels for "no pong and no bytes for 1m0s" and re-dialing onto unmeasured sudph routes, which cost the set ten minutes of degraded goodput for a thirty-second stall.
So a retire now needs a FAILED PROBE as well: a ping this client sent that came back an error, or one that has been outstanding longer than tunnel.probe_fail_window. An idle standby that nobody pinged successfully *because the local visor was not moving packets* has no failed probe — its ping is merely outstanding, and a few seconds later the pong arrives and the tunnel is alive, which is the truth.
Package skysocks pkg/skysocks/tunnel_prior.go — the capacity PRIOR a tunnel carries before it has carried anything.
The defect this answers. A standby tunnel is held open, pinged and kept alive, and it carries no bytes by definition — so tunnelMeter.sample, which learns only from a window in which the tunnel carried streams (#4965), never gives it a capacity. Everything downstream then had to invent one, and both inventions were the same mistake in different places: the spread planner credited an unmeasured route the BEST weight present ("so it is probed rather than starved"), and min_routes promoted whichever standby the RTT rank liked. A route that had never moved a byte was therefore promoted first AND weighed like the fastest route in the set — measured on the rig at x0.605 down and x0.157 up when spread.max_share/min_routes were made the default (bench/2026-09-18/a0df50285-smoke/mux-spread-3.*).
The honest number was already on the visor. Every transport carries transport.Entry.ThroughputBps — the passively observed peak goodput, a LOWER-BOUND capacity estimate refreshed by the dispersion probe — and a tunnel is built out of transports. So a tunnel that has proven nothing about itself still has something true said about it by the hops it runs over, and that is its prior.
It reaches the app through the channel the app already uses to see its own tunnels: the ProxyStatus RPC (pkg/proxystatus). The snapshot gained one field per hop (Hop.ThroughputBps) and one per tunnel (Tunnel.LocalPort, the name the app, the visor and the bench's carrier.tsv already share for a tunnel); nothing new was wired between the app and the visor.
Package skysocks pkg/skysocks/tunnel_promoter.go c4-app-proxy tunnel_promoter.go holds the promoter: the tick that swaps a held standby tunnel in for a worse active one, and the idle audition that gives a standby tunnel a capacity measurement it could not otherwise have.
Package skysocks pkg/skysocks/tunnel_snub.go — the per-TUNNEL snub, borrowed from bittorrent.
A previous attempt at this bounded time-to-first-byte per CHUNK and thrashed, because a chunk queued behind two others on its tunnel legitimately waits seconds before its first byte and there is nothing wrong with the tunnel. Bittorrent does not time a REQUEST; it snubs a PEER that has sent nothing for a while however many requests are outstanding on it, and unchokes it again later with a single request to see whether it came back. That is the right shape here too: the unit is the tunnel, the evidence is any byte on it, and a chunk's own queueing is invisible to the rule.
So the meter records lastByteAt (any chunk BODY byte read on this tunnel) and lastAckAt (any upload ack, or the sink's flow-control window advancing under a chunk body write — a write that COMPLETES means the far end consumed what came before it). A tunnel holding at least one outstanding chunk or slot that has produced neither for its bound is snubbed: its in-flight chunks are aborted and refetched elsewhere free of budget, the picker stops assigning it new ones, and after tunnel.snub_hold it is re-tried with exactly ONE chunk — bittorrent's optimistic unchoke, and the only way to learn that a tunnel recovered without giving it a share of the object first.
The bound is max(tunnel.snub_after, 2 x the tunnel's smoothed RTT). The floor is what keeps a 470 ms Sydney tunnel out of the rule: three quarters of its budget is one round trip, and a fixed 3 s would snub it for being far away.
A SNUB MUST NEVER RE-ISSUE WORK WHOSE ORIGINAL ATTEMPT IS STILL MOVING at the mux level, and the bound is the only app-local instrument for that. A route group is a reliable ORDERED stream: while its receive frontier is wedged behind a missing packet, nothing whatever reaches the app — no chunk byte, no upload ack, not even the keepalive pong — so a head-of-line-blocked tunnel and a dead one look identical here, and they differ only in how long the silence lasts. The router sees the difference (it is still taking SACKs and retransmitting), but no app RPC carries that: pkg/app.Client has no route-group accessor, and the proxystatus.Snapshot the status page pulls carries neither the reorder frontier nor a route group's local port to key it by. So the bound is set above the longest measured wedge clear instead — tunnelReorderWedgeClear below. With the old 3 s default a single wedge cost six snub/unsnub pairs on one port and re-issued ~4 MB of outstanding chunks each time, which is where 50 MB objects turned into 57.6-63.1 MB of wire.
LIVENESS IS NEVER THE SNUB'S PROBLEM. If every active tunnel is silent, the silence is not a tunnel fault — it is the origin, the exit, or the whole mesh — and re-issuing the object's chunks onto tunnels that are equally silent buys nothing while costing every chunk its progress. planSnubs therefore snubs NOTHING when the candidates are the whole active set.
Package skysocks pkg/skysocks/udp.go c4-app-proxy
SOCKS5 UDP ASSOCIATE over skywire.
RFC 1928 relays datagrams out of band: the server answers ASSOCIATE with the address of a UDP socket of its own, and the application sends datagrams there. Over a mesh there is no UDP path from the application to the exit, so the association is split in two and the datagrams ride the route:
app --UDP--> skysocks-client --yamux stream--> exit --UDP--> target
The client binds the relay socket the application was promised and frames each datagram onto a yamux stream; the exit unframes them, sends them from one unconnected UDP socket, and frames the replies back. The datagram on the wire is the application's own SOCKS5 UDP encapsulation, unchanged, so the destination is named per datagram exactly as RFC 1928 says and the exit is the one that resolves it.
A relay stream is told apart from a SOCKS5 session by its first byte. A SOCKS5 greeting always opens with 0x05, so a magic that does not is unambiguous after a single byte — which matters, because that byte has to be readable without waiting for bytes a client may not send until it has been answered.
Package skysocks pkg/skysocks/upload_stripe.go — striped, resumable uploads.
The download direction survives losing a tunnel because a byte range addresses a chunk INDEPENDENTLY: a chunk whose stream dies is re-fetched on a live tunnel and the browser's next byte arrives in 0.6-1.2 s. A POST had no such addressing — one stream, one tunnel, an unknown prefix consumed at the origin — so the same cut that downloads survive lost 3 of 3 uploads (bench/2026-09-16/6745065a5-degrade: 30/45/44 MB of 50 delivered, hash_ok 0/3, curl seeing only the 100 Continue).
Against a sink that OPTS IN (the `X-Chunked-Upload: bytes` advertisement of cmd/skywire-cli/commands/proxy/loadtest.go) the upload gets the download's property: the body is cut into offset-addressed chunks, each chunk is its own PUT on its own stream spread across the tunnels, a chunk is durable only on its 2xx, and a chunk whose tunnel dies is re-sent at once on another one. The chunk that completes the object answers with the sink's whole-object hash, which is what the browser sees for its original request — so a hash check does not care which path carried the bytes.
Opt-in is a single explicit header, probed per origin and cached. It is never inferred: sending a partial body to an origin that did not advertise it is data corruption. An origin that does not opt in keeps today's behavior, with one addition — a body small enough to buffer is replayed whole on a surviving tunnel if the one under it dies before the origin committed a response (spliceReplayable).
Index ¶
- Constants
- Variables
- func DialTunnelRole(tunnels int, routed, standby bool) string
- func LoadOrCreateMITMCA(dir string) (*x509.Certificate, skynetca.LeafMinter, error)
- func ReuseListen(addr string) (net.Listener, error)
- func ServeDisconnected(ctx context.Context, lis net.Listener, appCl *app.Client, ...)
- func ServeDisconnectedWait(ctx context.Context, addr string, appCl *app.Client, d time.Duration, ...)
- func Splice(browser, resolver net.Conn)
- type Client
- func (c *Client) AddStandbyTunnel(conn net.Conn) error
- func (c *Client) AddTunnel(conn net.Conn) error
- func (c *Client) Close() error
- func (c *Client) IsStandby(s *yamux.Session) bool
- func (c *Client) ListenAndServe(addr string) error
- func (c *Client) ListenIPC(client *ipc.Client)
- func (c *Client) MITMCACertPEM() ([]byte, bool)
- func (c *Client) PoolFilter() (excludePKs, requireTpTypes []string)
- func (c *Client) Serve(l net.Listener) error
- func (c *Client) SetHTTPSRangeSplit(caDir string) error
- func (c *Client) SetHTTPSRangeSplitMinter(cert *x509.Certificate, minter skynetca.LeafMinter)
- func (c *Client) SetHTTPSRangeSplitOriginRoots(pool *x509.CertPool)
- func (c *Client) SetLocalResolverRefresh(refresh func() *LocalResolvers)
- func (c *Client) SetLocalResolvers(r *LocalResolvers)
- func (c *Client) SetPoolDial(fn func() (net.Conn, error))
- func (c *Client) SetRangeSplit(enabled bool, concurrency int, chunkSize int64)
- func (c *Client) SetRangeSplitPort(port int)
- func (c *Client) SetReserveAdopt(fn func(port uint16) (net.Conn, error))
- func (c *Client) SetStandbyPool(n int)
- func (c *Client) SetTunnelRedial(fn func() (net.Conn, error))
- func (c *Client) SetTunnelTarget(n int)
- func (c *Client) StandbyPoolState() (held, standby int, settled bool, settledAt time.Time, reason string)
- type LocalResolvers
- type Server
- type ServerOption
Constants ¶
const ( // TunnelRoleActive is a tunnel the picker may put streams on. TunnelRoleActive = "active" // TunnelRoleStandby is a tunnel held open, kept alive and measured, but // carrying no streams while an active tunnel is live. TunnelRoleStandby = "standby" )
Tunnel roles, as reported to the visor at dial time (router.DialOptions TunnelRole) and shown by `visor state --select mux_route_groups`, `proxy mux info` and the proxy status page. They are the DIALING end's own labels: an exit sees a peer's tunnels but has no idea which of them are in standby.
const TunnelRoleSnubbed = "snubbed"
TunnelRoleSnubbed is the role a snubbed tunnel wears in the visor's mux view (`visor state --select mux_route_groups` .tunnel_role, and `proxy mux info` role=). It is deliberately the same field active/standby use rather than a new one: the bench reads one label per tunnel and "what is this tunnel doing right now" has one answer at a time.
Variables ¶
var ErrClientClosed = errors.New("skysocks: client is closed")
ErrClientClosed is returned by AddTunnel / AddStandbyTunnel when the client has already been closed. The caller owns the conn it was about to hand over and must close it.
Functions ¶
func DialTunnelRole ¶
DialTunnelRole is the role the app stamps on ONE dial, and the only place that decision is made — the app's dial helper (cmd/apps/skysocks-client) and this package's own tests read the same function.
It must be right at DIAL time rather than at the first report: a role-less group is a group the visor cannot place, and the router now refuses to widen one at all (pool_arbiter.go poolWideningAllowed). Every dial that fills or refills the ACTIVE set is active; every standby-pool fill is standby.
tunnels is --tunnels, routed is --routed, standby marks a pool fill. A single-tunnel session that never asked for a route group gets no role: it has no pool and nothing to arbitrate.
func LoadOrCreateMITMCA ¶
func LoadOrCreateMITMCA(dir string) (*x509.Certificate, skynetca.LeafMinter, error)
LoadOrCreateMITMCA loads the unconstrained MITM root from dir, generating and persisting one on first use. dir/ca.crt is the cert to import into the browser; dir/ca.key is its private key (0600). Returns the cert, a leaf minter that will sign for any host, and the cert itself for export.
func ReuseListen ¶
ReuseListen binds a TCP listener with SO_REUSEADDR + SO_REUSEPORT so the skysocks-client can rebind its SOCKS port without racing the previous socket's TIME_WAIT.
The client rebinds :<addr> twice on EVERY --reconnect cycle: the sessionless "disconnected" listener that serves the branded interstitial while dialing, then the live Client's own listener once the exit is up. Any tunnel-down event (a dead mux leg, an exit restart) ends a cycle and starts a new one, so the port is reopened constantly under normal operation. With a plain net.Listen the reopen could hit "bind: address already in use" (the just- closed socket still in TIME_WAIT) and DROP the app — the exact fail-the-app lifecycle bug. SO_REUSEADDR lets the bind reclaim a TIME_WAIT socket; SO_REUSEPORT covers the brief within-cycle window where the disconnected and live listeners momentarily both hold the addr during handoff.
func ServeDisconnected ¶
func ServeDisconnected(ctx context.Context, lis net.Listener, appCl *app.Client, resolvers *LocalResolvers)
ServeDisconnected serves lis while the client has NO session to the exit yet (still dialing, or the exit route group never came up). Each accepted connection is answered locally by disconnectedConn. It returns when ctx is canceled (the dial succeeded and the caller is handing :1080 to the live Client, or the proc is shutting down) or lis fails; lis is closed on return so the port is released for the live listener. The caller owns lis.
func ServeDisconnectedWait ¶
func ServeDisconnectedWait(ctx context.Context, addr string, appCl *app.Client, d time.Duration, resolvers *LocalResolvers)
ServeDisconnectedWait owns addr for d (or until ctx is canceled), answering browser connections locally, then releases the port and returns.
It exists to close the RECONNECT GAP — the one proxy state in which status.skysocks was not answered in-process at all. When every tunnel dies, (*Client).ListenAndServe tears the live :1080 listener down and returns, and the app's reconnect loop then SLEEPS (2 s doubling to 30 s) before the next cycle binds its disconnected listener and starts dialing. Across that sleep — and it is entered on every visor restart, `proxy stop`/`proxy start` and exit flap — nothing held :1080, so every browser request was connection-refused: status.skysocks included, and the branded interstitial's own auto-retry included. The browser then shows its "proxy server is refusing connections" page, which has no retry of its own, and the tab is dead until a human reloads. Serving the sleep instead of sleeping through it means the reserved diagnostic page is answerable in EVERY proxy state — starting, dialing, no route, route down, and now between cycles.
The port is released on return, so the next cycle's listener (and ultimately the live Client's) rebinds cleanly; a bind failure degrades to a plain wait rather than skipping the delay, so it can never turn into a dial hot loop.
func Splice ¶ added in v1.3.99
Splice copies between a browser conn and a resolver conn until both directions have ended, half-closing each way as it goes so a client that shuts down its write side once its request is complete still receives the whole reply — the same half-close rule the exit splice follows.
It is here rather than on Client because the disconnected listener has no Client and serves mesh names too.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client implement multiplexing proxy client using yamux.
The client holds N independent yamux sessions ("tunnels"), each over its own dialed route-group conn. Accepted browser connections are striped across the tunnels by pickSession (least-loaded live tunnel), so throughput sums across disjoint routes with zero cross-tunnel reorder — the connection-striped aggregation design in docs/mux_aggregation_rfc.md. N is configurable; the default is a single tunnel (N==1), which is byte-for-byte the pre-aggregation behavior. Multiple tunnels only genuinely aggregate once they leave over disjoint first-hop transports; the visor steers the extra tunnels onto disjoint first-hop transports (#4214), and this Client keeps N of them healthy — a dead tunnel is re-dialed as a fresh disjoint replacement (maybeRedial, RFC steps 3-4). Throughput-based eviction of a slow-but-alive tunnel is a follow-up.
func NewClient ¶
NewClient constructs a new single-tunnel Client. Signature unchanged: this is the common case and every existing caller/test builds one tunnel this way. Use AddTunnel (or NewMultiClient) to stripe browser connections across N tunnels.
func NewMultiClient ¶
NewMultiClient constructs a Client striping across one tunnel per conn. With a single conn it is identical to NewClient. The N conns must already be dialed (ideally over disjoint routes — see the disjoint-dial follow-up in docs/mux_aggregation_rfc.md step 3); this constructor only wraps them.
func (*Client) AddStandbyTunnel ¶
AddStandbyTunnel is AddTunnel for a POOL tunnel: the session joins the set but is marked standby, so the picker passes it over while any active tunnel is live. The keepalive loop treats it like any other tunnel — same 5 s RTT ping, same hard-dead window — which is the whole point: its measurement is current the moment it is needed.
func (*Client) AddTunnel ¶
AddTunnel wraps an additional dialed route-group conn in a yamux session and appends it to the tunnel set. The shared keepalive loop and pickSession pick it up automatically. This is the extension point the disjoint-dial coordinator (RFC step 3) will call to grow the tunnel set at runtime.
func (*Client) IsStandby ¶
IsStandby reports whether s is held in the pool rather than carrying streams.
func (*Client) ListenAndServe ¶
ListenAndServe start tcp listener on addr and proxies incoming connection to a remote proxy server.
func (*Client) ListenIPC ¶
ListenIPC starts named-pipe based connection server for windows or unix socket for other OSes
func (*Client) MITMCACertPEM ¶
MITMCACertPEM returns the PEM of the HTTPS range-split MITM root for the operator to import into a browser. ok is false when HTTPS range-splitting is not configured.
func (*Client) PoolFilter ¶
PoolFilter is the standby pool's live candidate filter as the app's dial closure must pass it: the peers a pool dial may not use, and the transport types its first hop must have. Both empty by default, which is a filter that filters nothing.
It lives on the Client because the knobs are the client's (it is the client that pulls them), and it is read per dial so a change lands on the very next pool dial rather than at the next restart.
func (*Client) Serve ¶ added in v1.3.99
Serve proxies the connections l accepts until the client closes, as ListenAndServe does on a listener it makes itself.
func (*Client) SetHTTPSRangeSplit ¶
SetHTTPSRangeSplit is a convenience that loads/creates the MITM root under caDir and enables the feature on this client in one call. Prefer LoadOrCreateMITMCA + SetHTTPSRangeSplitMinter when the CA must exist before the first client is built (so it can be exported up front). Errors (unreadable/creatable CA) leave the feature off.
func (*Client) SetHTTPSRangeSplitMinter ¶
func (c *Client) SetHTTPSRangeSplitMinter(cert *x509.Certificate, minter skynetca.LeafMinter)
func (*Client) SetHTTPSRangeSplitOriginRoots ¶
SetHTTPSRangeSplitOriginRoots overrides the roots used to verify the REAL origin's certificate (nil = system roots). Intended for tests that stand up an httptest TLS origin; production leaves it nil so origin security is never downgraded.
func (*Client) SetLocalResolverRefresh ¶ added in v1.3.99
func (c *Client) SetLocalResolverRefresh(refresh func() *LocalResolvers)
SetLocalResolverRefresh installs the lookup that re-reads the visor's published resolving proxies, consulted on the keepalive tick while this client has none.
It exists because this app and the resolvers are all launcher apps started in no fixed order: an empty answer at connect time often just means the resolver had not published itself yet, and waiting for the next reconnect cycle to find out would be hours of mesh names going to an exit that cannot reach them.
func (*Client) SetLocalResolvers ¶ added in v1.3.99
func (c *Client) SetLocalResolvers(r *LocalResolvers)
SetHTTPSRangeSplitMinter enables TLS-terminating (:443) range-splitting on this client using a MITM root + minter the CALLER already created (via LoadOrCreateMITMCA). This is the production path: the CA is a persistent local identity minted ONCE at app startup — independent of any dial — and the same minter is injected into every reconnect's client, so the operator can import the cert before traffic ever flows and it never changes underfoot. SetLocalResolvers installs the visor's in-process resolving proxies, so a CONNECT to a name one of them answers for (<pk>.skynet, <pk>.dmsg) is served by the resolver over this app's own data plane instead of being sent to the exit, which cannot reach it. One browser proxy setting then covers the mesh and the clearnet, and a mesh name works with no exit at all.
Call before ListenAndServe. A nil set (no resolvers published, or an app with no visor) leaves every request going to the exit, as before.
func (*Client) SetPoolDial ¶
SetPoolDial wires the app's REQUIRE-disjoint dial into the Client so the standby pool can grow itself. It mirrors SetTunnelRedial — the dial lives in the app (cmd/apps/skysocks-client) because it needs the server PK, the retrier and the appnet fallback machinery — but differs in one decisive way: this dial demands a first hop no tunnel already held occupies, and returns router.ErrNoDisjointFirstHop rather than conceding a shared one. A shared extra tunnel aggregates nothing, and the refusal is the signal the pool stops on. A nil fn (the default) leaves the pool disabled.
func (*Client) SetRangeSplit ¶
SetRangeSplit configures transparent HTTP range-splitting. concurrency<1 or chunkSize<1 keep the current value; enabled=false disables the feature entirely (every request splices through unchanged). The app wires this from its flags so the capability is default-on but tunable, not gated behind a required flag.
func (*Client) SetRangeSplitPort ¶
SetRangeSplitPort sets the destination port the splitter treats as plaintext HTTP (default 80). Non-positive values keep the default.
func (*Client) SetReserveAdopt ¶
SetReserveAdopt wires the app's adopting dial: a dial to the exit's service port that names the reserve by its far-end port.
func (*Client) SetStandbyPool ¶
SetStandbyPool sets the CEILING on tunnels held to the exit, the active ones included, and arms the fill. n <= the active target means "active tunnels only" and leaves the pool off — so --standby-pool 0 is byte-identical to the behavior before the pool existed.
It is a ceiling and not a target: the fill stops for good at the first router.ErrNoDisjointFirstHop, so an exit with three disjoint first hops settles at three and never dials again until a tunnel dies. Setting it does not reap anything — an already-held tunnel is never given up, because the active target alone decides which tunnels carry streams.
Which of the two actually stops a given fill depends on how many transports the visor holds, since a first hop is one of its own transports. A visor with a handful settles on exhaustion; one on the public network settles on this ceiling with plenty left — the rig's eighth pool dial had 124 free ranked candidates. StandbyPoolState's reason says which happened.
func (*Client) SetTunnelRedial ¶
SetTunnelRedial wires the app's disjoint-diversify dial into the Client so the keepalive loop can re-dial a dead tunnel's replacement. The dial itself lives in the app (cmd/apps/skysocks-client) — it needs the server PK, the retrier and the appnet fallback machinery the Client has no handle on — so the app closes over its dialServer(...,diversify=true) call and hands it here. fn must return a fresh dialed route-group conn (the same kind NewMultiClient wraps); the loop wraps it in a yamux session via AddTunnel. A nil fn (the default, and the single-tunnel case) disables re-dial entirely.
func (*Client) SetTunnelTarget ¶
SetTunnelTarget sets the desired number of live tunnels N. When a tunnel dies and the live count falls below N (but at least one tunnel survives), the keepalive loop re-dials a replacement via the SetTunnelRedial callback. The app sets this to --tunnels so a re-dial restores the full aggregation width even if some initial dials fell short. Values < 1 are clamped to 1 — a single tunnel never re-dials (its death is total collapse, owned by the app's --reconnect).
func (*Client) StandbyPoolState ¶
func (c *Client) StandbyPoolState() (held, standby int, settled bool, settledAt time.Time, reason string)
StandbyPoolState reports the pool's size (tunnels currently held, active and standby), whether the fill has settled, when, and WHY.
The reason is the part a bench needs, because "settled" alone conflates two very different outcomes: "pool ceiling" means --standby-pool is what stopped the fill and the topology had more to give, while "no disjoint first hop left" means the fill really did reach the topology's bound. On a visor holding a hundred-odd transports the first is the ordinary case — measured on the rig 2026-09-17, the eighth pool dial still had 124 free ranked candidates to choose from.
type LocalResolvers ¶ added in v1.3.99
type LocalResolvers struct {
// contains filtered or unexported fields
}
LocalResolvers is the set of resolving proxies on this visor an app may hand a hostname to, and the dial that reaches one. A nil *LocalResolvers answers for nothing, so every caller can hold one unconditionally.
func NewLocalResolvers ¶ added in v1.3.99
func NewLocalResolvers(svcs []appnet.LocalService, dial func(port routing.Port) (net.Conn, error)) *LocalResolvers
NewLocalResolvers keeps the services that answer for at least one hostname suffix. dial must reach the service's port on this visor's own PK; a nil dial (or no suffix-bearing service) yields a set that answers for nothing.
func (*LocalResolvers) Open ¶ added in v1.3.99
Open dials the resolver on port and completes the SOCKS5 handshake with it on the browser's behalf: the greeting and CONNECT request the browser already sent are replayed byte-for-byte. The returned conn is positioned where the browser's conn is — the resolver's CONNECT reply is the next thing on it, for the caller's splice to carry back.
The exchange is strictly sequential, unlike the exit's pipelined open. A local service is served over net.Pipe, which has NO buffer: the resolver answers the greeting before it reads the request, so its reply write blocks until someone reads it — and a caller still writing a pipelined request would be that someone. Pipelining here deadlocks both ends. In-process there is no round trip to save anyway.
func (*LocalResolvers) PortFor ¶ added in v1.3.99
func (r *LocalResolvers) PortFor(host string) (routing.Port, bool)
PortFor returns the resolver that answers for host. A suffix matches on a label boundary, so ".skynet" matches "<pk>.skynet" and "status.skynet" but never "notskynet".
func (*LocalResolvers) Suffixes ¶ added in v1.3.99
func (r *LocalResolvers) Suffixes() []string
Suffixes lists every hostname suffix this set answers for, for a log line.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server implements multiplexing proxy server using yamux.
type ServerOption ¶ added in v1.3.99
ServerOption configures a Server at construction.
Source Files
¶
- accept_stats.go
- chunk_plan.go
- client.go
- client_live_ops.go
- client_shape.go
- common.go
- disconnected.go
- listen_unix.go
- localresolver.go
- pool_auto.go
- rangesplit.go
- rangesplit_https.go
- server.go
- settings.go
- spread.go
- tunnel_adopt.go
- tunnel_depth.go
- tunnel_liveness.go
- tunnel_prior.go
- tunnel_promoter.go
- tunnel_snub.go
- udp.go
- upload_stripe.go
Directories
¶
| Path | Synopsis |
|---|---|
|
Package skysettings pkg/skysocks/skysettings/skysettings.go
|
Package skysettings pkg/skysocks/skysettings/skysettings.go |