Back to list
Skywire Development Update — September 12, 2026

Skywire Development Update — September 12, 2026

The relay tier got most of the day, and it got it in the order a feature usually gets attention: first the thing that makes it useful, then the things that were quietly making it useless. A service can now hold its own fixed dmsg identity without paying for a second client’s worth of sessions, a relay can now deliver to a destination its host can reach rather than only to peers already attached to it, and a host’s own service is no longer charged to the budget it set aside for other people. Underneath that, the day’s most consequential find had nothing to do with relays at all: folding a dmsg server onto a visor’s key had silently removed its WebSocket front, so seven of the network’s nine servers had been unreachable from any browser. Two separate ways of losing a session you were still using were traced and fixed, a visor-global setting that had been disabling the very shortcut it was meant to help was deleted, and four days of red CI lanes were finally cleared.

Skywire: A Fixed Key Without a Second Client

Some keys cannot move. The resolving dmsg proxy answers under a key the survey whitelist knows, so rotating it means updating the deployment — and until now the only way to keep it was to run a second, full dmsg client, with its own sessions to every server, its own discovery entry, and re-registration on every restart. 4807 opens a third path. A standalone client with its own keypair, its own binary and no router at all can attach to the visor beside it over a unix socket, keep its key, hold exactly one session, publish no entry, and have its streams ride the visor’s existing sessions and transports. The host pays for dmsg once.

The mechanism is deliberately not new. AcceptRelaySession already took a plain connection and preserved the attached peer’s identity, so this is a third pipe into the existing relay rather than a parallel one — the session stays a skynet-carrier session at the visor’s relay port, and all twenty-one existing call sites work unchanged. The one wire addition is a forty-byte hello carrying the visor’s public key, required because the handshake is Noise XK and the initiator must know the responder’s static key up front; without it every attaching tool would have to learn that key out of band and would get an opaque hang when its guess went stale. Authorization is two independent layers: a 0600 socket, which grants a process nothing it did not already have — it could read the visor’s secret key off disk — and an allowed_keys list checked at the same point the skynet acceptor checks it. A TCP address is offered for containers and Windows, and the visor refuses to bind one unless the key list is non-empty and the address is loopback.

4808 takes the next step for a service that lives inside the visor already: dmsg_web.secret_key runs the embedded resolver under its own identity, attached over an in-memory pipe. No socket, no listener, no hello — the dial is the attach. On a live visor both deployment services answered through the resolver under the second identity while the host’s session list stayed at six, exactly what it had before. This is explicitly not the throwaway-key client removed earlier in the year: that one registered, and churned 660 discovery identities in a fortnight. A guest here never registers, which also bounds what it suits — an outbound resolver, not a service that has to be reachable on its own.

Skywire: Configuration That Survives Autoconfig

A feature that can only be hand-written into the JSON is a feature the next skywire autoconfig deletes. Three merges close that gap for everything landed above. 4825 turns the local relay acceptor on by default, on the grounds that the flag was defending nothing: the socket is 0600 owned by the visor’s user, and on a packaged install the visor’s config — which holds its secret key — is 0600 owned by the same user, so anything that can open the socket can already read the key outright. What the default did cost was a config edit and a restart before a host could even try running a service under its own key. The field becomes a pointer so that “unset” and “an operator said no” stay distinguishable across a config rewrite.

4826 adds DMSGRELAYADDR, DMSGRELAYKEYS and NODMSGRELAY, with matching config gen and autoconfig flags, after the default-on socket turned out not to be enough on its own: the socket carries the visor’s uid, and on a packaged install that is root, so a service running as any other user cannot open it regardless of mode, because the group is the visor’s group too. The answer was already in the config — a loopback TCP address gated by a key allowlist, which authorizes an identity rather than whoever happens to run as a uid — it simply had no way in. An address given without a key list is now refused outright rather than quietly opening an ungated acceptor. 4828 does the same for the resolver’s key as DMSGWEBSK. The live case is a survey proxy running today as a separate unit holding eight dmsg sessions of its own; the embedded resolver replaces that with one attached session and serves both faces from one entry point, measured at 595ms for a .dmsg fetch and 4.4ms for the .skynet one against the same destination.

4819 removes the most alarming thing about running an attached client: it logged “No entries found. Retrying after…” every few seconds for as long as it was up and working — eleven times in ten minutes — because the list of relay nominees needing a session goes empty at the exact moment the attach succeeds, and an attached client’s direct discovery is empty by design. The serve loop read the two emptinesses together as “nothing to connect to”. The correct resting state already existed in that loop; it was simply unreachable from a client that had nothing left to dial.

Skywire: A Relay That Reaches As Far As Its Host Does

A relay refuses to forward over its own skynet-carrier sessions, because doing so would chain a relay through a relay: unbounded path, no loop prevention, far end free to forward again. That is right in general and wrong in exactly one case — when the session’s remote is the destination, the next hop is the end of the line and there is nothing to chain. 4829 adds that leg explicitly, and the difference is the whole reach of the feature: on a live hub, a guest could previously address the two peers already attached to that relay, against the 504 transports the relay itself could reach.

That gap mattered beyond convenience. The survey and pprof surfaces are gated on the caller’s key, and only dmsg carries the caller’s key end to end — the stream request is signed and verified against the source address, while the skynet mirror of the same surface reports the routing visor instead. A survey-whitelist proxy therefore has to reach visors over dmsg, including visors that hold no dmsg server session at all but are reachable over skynet from its host.

4827 fixes the other half of the same misfit. relay_max_streams bounds what a visor carries for other people, but every attach path was charging the host’s own services against it — so a visor that set the cap negative in order to relay for nobody silently broke its own resolver, and at the default cap that resolver competed with strangers for slots. The consequence is worst where it is least visible: a remote peer refused by a full relay falls back to its own server sessions, but a relay-only guest has no server sessions to fall back to, so a refusal is fatal rather than slow. The accept path splits, and only the remote entry point is charged.

Earlier in the day 4812 had given each attached peer at most a quarter of the budget rather than letting the first arrival take all of it, and 4813 had exempted the session-carrier dial from min_hops — extra hops hide nothing from the one peer that terminates the session and therefore already knows whose it is, while the requirement made the carrier dial fall through to route setup reached over dmsg, the very thing the carrier is replacing. It also gives relay refusals a counter in visor state, since at the dialer a refusal is indistinguishable from the destination being down.

Skywire: A Folded Server Served No wss

cli mdisc check reported that two of the network’s nine dmsg servers had a working WebSocket front, and the two that worked were exactly the two that had never been folded onto a visor key. Six of the folded servers advertised no WebSocket address at all; the seventh advertised one that refuses on :443. Since WebSocket is the only way a browser or wasm visor reaches a dmsg server, seven of nine had been unreachable from the browser since the fold.

4833 explains it and fixes it. A standalone dmsg server serves dmsg-over-WebSocket on its main port and advertises it under its DNS label. Folded into a visor it shares the visor’s transport port, whose HTTP/1 branch already belongs to the WS transport — so the dmsg WS front silently vanished, and the folded server’s config could not even express one. Nothing else about the fold was wrong: the port is unchanged, every current key’s DNS label resolves to its own host, and all of them are in the embedded deployment config, so only the routing was missing. The branch now routes by path, /dmsg to the dmsg server and everything else to the WS transport — the same shape the hypervisor UI port already uses. Deployment still needs the TLS front restored on the one host whose :443 refuses.

4836 is that restoration, and it explains why only some folded hosts needed it. A standalone dmsg server terminates its own TLS on :443 through an autocert listener; folding it onto a visor retires that process along with the certificate’s only user, so a folded server advertising wss:// over a reused label dials straight into a closed port, connection refused, with the certificate still sitting unused in /opt/skywire/dmsg-autocert. Not every host was fronted that way, so the fix is a knob rather than a default: ws_tls_address on the visor’s dmsg server config, same name and meaning it has standalone — empty leaves TLS to whatever reverse proxy already owns :443, ":443" self-terminates and reuses the certificate already on disk. DMSGSERVERWSTLS carries it through skywire.conf so it survives the next autoconfig run.

4838 found the fix was not quite complete. Even with the route restored and TLS terminating, three production visors still answered /dmsg with a 404 — the route existed, but the handler behind it was empty. A ClientFactory travels by value through both visorcore.TransportManagerDeps.Factory and transport.NewManager, so the client the WS listener read was a copy of the one the visor had stored a pointer to; SetDmsgWSHandler wrote to the original, and the copy’s handler field, a bare atomic.Value with no indirection of its own, never saw it. go vet’s copylocks check cannot catch this, since atomic.Value carries no noCopy marker, so the fix pins the invariant with a test instead: the field becomes a pointer, allocated once before any client is built, so every copy shares the same slot.

Skywire: Two Ways to Lose a Session You Were Using

4834 traces a session-eviction race to its root. The function that obtains a session re-checks the session map and then dials without holding the lock — it must not hold it, because the dial path can re-enter session establishment through a self-hosted discovery and would deadlock on itself. So callers arriving together for a server with no session all miss the check and all dial, and since a dmsg server keeps one session per client key, each new session evicts the previous one at the server while the client’s own newest-wins registration closes it again locally. Every racing caller but the last is left holding a session that was just closed from the far end. The pinned-rendezvous hostname in the dmsg web proxy is how a user meets this: one page is several requests, each its own SOCKS5 connect, each dialing the named server — six parallel fetches dialed six sessions, failed with session shutdown, and cost the proxy a live session and every stream on it. Session establishment is now coalesced per server key, covering only the dial and not the discovery lookup before it, so the re-entrancy the lock is released for cannot land inside the flight.

4835 is the same family, found while fixing it. The public-IP probe obtains a session — which returns an existing one on a cache hit — and then closes the result unconditionally, tearing down a session the client is using for everything else. Two passes make it likely rather than theoretical: the first pass correctly leaves a connected server’s session open but skips that server when it reports a non-public IP, which is exactly the LAN and loopback case, and the second pass then borrows the session the first pass declined and closes it. It self-heals, because the serve loop redials, and so it only ever surfaced as unexplained session churn with in-flight streams lost. The fix is narrow: identical behaviour when the probe dialed the session itself, and it declines to close one it merely borrowed.

4837 closes a related gap in the same pinned-rendezvous path. A client attached to its visor’s local relay holds exactly one session and an empty discovery by construction — that is what attaching is — so a pin of the form <server-pk>.<dest-pk>.dmsg failed the entry lookup outright for an attached caller, including the survey resolving proxy, which is the kind of process attaching exists for. A pin names where the destination can be found, not which session has to serve it, and a relay already tries its own sessions before any discovery lookup and, since 4829, can deliver over a terminal skynet leg straight to the destination visor. DialStreamVia now falls through to that ladder when a relay is available, and keeps the original behaviour — failing on the one named server — when there is none to ask.

Skywire: A Route Count Is Not a Setting

4803 deletes the visor-global mux_routes. How many routes a flow should hold depends on its bandwidth demand and on which transports and intermediates exist between two visors at that moment, and transports come and go — that decision belongs to the routing policy, which already computes a live degree and holds warm standby legs, and a fixed number fought it. As a global it was worse than inert. With mux_routes: 1 in the config the per-dial value was never zero, and the zero-hop direct-mux shortcut tests for exactly that, so the shortcut never fired and every app dial went through full route setup — including the --direct dials that exist precisely to avoid it. Starting a proxy with --direct against a peer the visor already held a transport to produced seventy-two connected to setup node lines in a single run; the setup node is only needed to route through a third party.

The per-invocation --mux flag had the same shape, setting visor-wide state that left every later plain proxy start silently multiplexed across restarts. The per-dial option, the runtime proxy mux width/standby control and the app-level flags all stay; min_hops stays global, because it genuinely is a visor-wide privacy property. 4817 rebuilds the embedded manager bundle the removal had left disagreeing with its own source.

Skywire: Numbers That Disagreed, and Flags That Were Ignored

cli tp net-stats reported 15,388 transports while the transport discovery itself returned 11,551, and stayed frozen on that number for twenty minutes, while the command that skips the CXO step tracked the network correctly — the same endpoint giving two answers. 4816 found the CXO read bracketing itself with acquire and release, so the refcount falls to zero the moment the call returns and the refresh cycle stops, after which the snapshot ages in place; the resync path was only ever reached when the leaf was absent, so once a leaf existed it was served unconditionally regardless of age. Reads are now gated on leaf age against the publisher’s own cadence, and a leaf still stale after a resync reports a miss so the caller falls through to the live service.

4815 fixes a quieter dishonesty: --jq and --shape are registered on the root command and documented as applying everywhere, but the discovery commands built their output through a pipeline that ended by writing straight past the printer that reads those flags, and nothing in the help text distinguished them. cli pv --jq length printed the list of public keys; it now returns 35, agreeing with the summary form, and default human output is byte-identical. 4823 answers a question that had no good answer before — which clients a dmsg server is actually carrying, and how hard. The discovery is not an answer, because an entry records what a client claims about the servers it delegated to, self-reported and stale by the refresh interval, with no per-client stream count at all. A co-resident visor and server knows the truth, and visor state --select roles now reports it, counting server-to-server peer links separately so a server does not look busier than it is while hiding the peer mesh.

Skywire: Four Days of Red Lanes

Every platform lane had been failing on every pull request, and the day closed by clearing both causes. 4820 fixes two bounded-retention tests that assert an eventual property — that asynchronous cleanup keeps storage bounded — by sampling it once, immediately, after a fixed twenty milliseconds per tick. That is enough on an idle machine and not enough on a runner writing four megabytes a tick under the full suite’s parallel load, where the sampled volume is the cleanup backlog rather than a leak. They now poll for the bound with a deadline; a real leak never settles, so it still fails, just later. 4824 clears the other half, eighteen linter findings across misspellings, unchecked writes in a compression test, explicit integer narrowings and two unused parameters — none of it a behaviour change, and all of it introduced by the linter action pinning itself to the latest release, which can turn every lane red at once without a line of project code changing.

4830 explains why macOS in particular had been red. A unix socket path is capped at about 103 bytes, and t.TempDir() derives its directory from the test’s name — under /tmp on Linux there is room to spare, but on macOS the base path is long enough before the name is added that one test came out at 119 bytes and another landed exactly on the limit. Both failed with EINVAL from bind, which reads as a broken socket rather than an over-long path; a two-character temporary prefix gives 71 bytes regardless of how a test is named, so naming stays free. Windows, which has neither unix sockets nor POSIX permission bits, skips them. That sweep also named an intermittent data race live on develop, which 4831 then fixed: the pty exec-stream keepalive was signalled to stop but never joined, and a response writer is valid only for the lifetime of its handler, so a loop already past its select could flush the same buffer that net/http flushes after the handler returns.

The day’s housekeeping was worth more than usual. 4814 drops 16.8MB of compiled demo wasm out of the vendored tree — a dependency embedded its whole documentation directory from its root package, and go mod vendor copies every embed target of a package it vendors across every build configuration, so each bump of that module wrote the wasm into this repository’s history again; upstream splitting the embed by directory takes the vendored module from 17MB to 196KB. And the help screen’s easter egg was reworked twice and then reverted once, in 4818, 4821 and 4822: the cloud gained six sizes chosen per screen, proper centering, a narrower backdrop that stops blanking the gap between a flag and its description, and Skycoin blue for the rain falling inside the silhouette. The version that painted the shape as a solid washed background with the rain knocked through it was the more legible of the two and the less liked — it reads as a panel bolted over the screen, where the version kept reads as the rain itself arranging into something.