Back to list
Jul 17 2026

Development Update — July 17

A big day, in the way that days get big when a production fire and a milestone feature land in the same twenty-four hours. A dmsg dependency bump had quietly flipped a default that took the data plane down fleet-wide, and undoing that sat next to a public-visor init stall it had exposed — two fixes that had to ship together. Alongside the firefighting: the browser visor finally got a small body, compiled under TinyGo and shipped next to the standard-Go blob; a brand-new app turned a visor into a WiFi router that puts a whole LAN onto the mesh with no client software; a leaked-connection deadlock deep in the transport-discovery CXO node got traced and broken; and the default binary shed about 44 MB with no loss of function. A stack of wallet, diagnostic, and CI hygiene work rounds it out.

Skywire: A Router the Whole LAN Can Join, With No Skywire

3498 feat(vpn-router): VPN gateway / WiFi-AP app is the first of its kind — a launcher app that turns a visor into a router so downstream LAN and WiFi clients reach the internet through the Skywire mesh VPN without running any Skywire software themselves. A laptop or phone joins the router’s WiFi or plugs into its LAN port, and its traffic is NATed into the mesh tunnel. It’s designed as a companion to vpn-client, not a replacement: vpn-client still owns the tunnel (tun0), unchanged, while vpn-router owns the downstream side — it brings up the LAN or WiFi interface, runs dnsmasq for DHCP and DNS, optionally runs hostapd to beacon a WiFi AP, and NATs the client subnet into tun0. Enable both apps and the router waits up to 90 seconds for the tunnel to come up, so start order is irrelevant. Two variants share one code path: an ethernet-out mode (--lan-ifc eth1, dnsmasq only) that works on the existing Skyminer SBCs, and a WiFi-out mode (--lan-ifc wlan0 --wifi --ssid … --passphrase …) where hostapd also beacons the SSID, given a chipset whose driver does AP mode. The NAT reuses the vpn-server primitives applied to the tunnel — EnableIPv4Forwarding, EnableIPMasquerading(tun0), and targeted LAN↔tun0 FORWARD rules — with teardown reversing everything and reaping the daemons via context. The config validation, DHCP-pool defaults, and generated dnsmasq.conf/hostapd.conf are unit-tested and the app builds and registers cleanly; the live interface/daemon/NAT path awaits validation on a real two-interface router box, with the procedure written up in the design doc.

Skywire: The Browser Visor Gets a Small Body

3497 feat(wasm-visor): TinyGo-compiled wasm-visor + dual-embed makes real the one place TinyGo’s size win truly matters. The full browser visor — dmsg, transports, router, appserver, net/http, and crypto/tls — now compiles under the 0magnet/tinygo fork (LLVM 22), producing a blob that’s about 2.9 MB gzipped against roughly 9.5 MB for standard Go: a decisive difference for the PWA. Crucially, this needs no requirement that skywire itself be TinyGo-compiled — the native visor stays standard Go. A standard-Go build now embeds both blobs, split into toolchain-matched pairs (wasmgo/ and wasmtinygo/), each carrying its wasm and its matching wasm_exec.js, since the Go and TinyGo loaders differ (TinyGo’s carries the WASI shims its module imports). A new wasmbin API exposes variant selection, and cli hv serve -W/--variant {go|tinygo} picks which blob the PWA receives with the matching loader; the default is unchanged. Both blobs stay committed and refreshed intentionally via make embed-wasm-visor, so CI never has to run TinyGo — the TinyGo build itself is about 4m43s, well within reach but kept off the critical path. Validated live: cli hv serve -W tinygo opened in a real browser boots the TinyGo visor, which generates its secp256k1 key, connects to dmsg, and registers in discovery with a real PK and populated delegated servers, with the Angular HV UI rendering against it. The TinyGo blob carries the documented feature gaps — the opus codec falls back to PCM, for one — so the full-featured Go blob remains the default.

Skywire: Three Fires on the Fleet

3510 fix(dmsg): restore proxyproto USE policy is the urgent one. go-proxyproto v0.15.0, pulled in by yesterday’s dependency bump, flipped the zero-value DefaultPolicy from lenient USE to REQUIRE — so a bare proxyproto.Listener now fails the first Read or Write of any connection that doesn’t open with a PROXY header. The client-facing dmsg data-plane listener is exactly that kind of listener: dmsg clients open with a raw Noise handshake, never a PROXY header. As dmsg servers auto-updated onto develop they began rejecting every visor connection outright — sessions couldn’t establish, and anything downstream of dmsg (hypervisor init, route and transport setup) went dark. The fix gives all four proxyproto.Listener sites (the dmsg data-plane and health listeners, dmsg-discovery, and tcpproxy) an explicit ConnPolicy returning USE, restoring the pre-v0.15 behavior of honoring a PROXY header if an upstream sends one while still accepting header-less direct connections — an explicit policy that survives future dependency bumps.

That outage exposed a latent fragility, which 3511 fix(visor): public-visor SD registration must not block init addresses. On a public visor the hypervisor was never initializing — the HV UI on :8000 never bound, and hv enable reported the init stalled. The root cause: serviceUpdater.Start() runs the best-effort initial client.Register(ctx) synchronously, and Register retries internally with backoff, so a slow or unreachable service-discovery blocks the call for minutes. Because Start() runs on the visor init path and the hypervisor module is ordered after public_visor, a blocked registration stalls the whole init tail. The proxyproto REQUIRE change is what surfaced it — SD registration over dmsg kept retrying against rejecting servers — but the fragility is independent: a slow SD should never be able to take down the hypervisor. The fix fires the initial registration in a goroutine tracked by the updater’s WaitGroup, leaving init non-blocking; the heartbeat loop just below is already the real recovery mechanism, retrying every 90 seconds, so nothing is lost.

The third fire lived deep in the transport-discovery CXO node. 3499 fix(cxo): reap leaked Conns + break the feeds↔head cyclic deadlock fixes a production incident diagnosed live via pty and /debug/pprof/goroutine: a connection leak driving the node toward OOM — roughly 500 leaked Conns, 3000 goroutines, 4.25 GB RSS, climbing 570 MB an hour — and the pipeline stall behind it. The trigger is a cyclic channel deadlock between the single nodeFeeds event loop and the per-head nodeHead loops over two unbuffered channels: the feeds loop blocks sending a received root into a head’s rrq, while the head loop blocks sending a broadcast back into nodeFeedsbrorq — and because the single feeds loop is both the producer to heads and the sole drainer of the heads’ return channel, blocking to produce means it stops draining, the head then blocks producing back, and neither proceeds short of node shutdown. The fix runs both feeds→head blocking sends on the nodeFeeds loop goroutine — the sole brorq drainer — so it keeps draining brorq while waiting to hand off, with no goroutine spawn that would reintroduce a leak. The second commit bounds memory during any such stall: nodeFeeds.receivedRoot ran synchronously on each connection’s receiveMsg, guarded only on the node’s closeq, so a connection that closed mid-stall never unblocked and its whole Conn leaked (pprof showed 203 parked in receivedRoot). Adding a case <-c.closeq lets a closing connection’s receiveMsg unblock and the Conn get reaped even when the pipeline is wedged — defense in depth that bounds memory to the live-connection set regardless of pipeline health, with a regression test that times out without it.

Skywire: A Lighter Binary

3500 chore: shrink the default binary ~44 MB trims two of the roughly 100 MB of assets the default binary embeds, with no loss of function, taking it from about 222 MB down to 175 MB. The GeoLite2-City.mmdb geoip database — 62 MB embedded raw in every visor and dmsg-server — is now committed gzipped at about 30 MB and decompressed once, lazily, on the first EmbeddedDB() call, cached via sync.Once; the []byte signature is unchanged, so in-process lookups and the standalone geoip service are unaffected. Separately, the experimental WebGL/WASM tpviz view (dist/main.wasm, about 11 MB) still needs work and isn’t the primary UI — the legacy JS view served at / is, and stays embedded — so the wasm dist/* embed now lives behind a -tags tpvizwasm build tag: the default build leaves it empty and returns a 404 with a rebuild hint from the /wasm endpoints, while a tagged build embeds and serves it exactly as before. The source and dist stay in the tree, just not embedded by default.

Skywire: The Embedded Wallet — Multicoin and Honest

The visor-embedded skycoin-web wallet got reworked onto skycoin-web’s native multicoin model in 3502 feat(wallet): skycoin-web multicoin via the /coin/ server-proxy model, replacing an earlier diverged approach that had smuggled an electrum URL into nodeUrl behind a /v1/btc/ fetch-intercept. skycoin-web is already multicoin — it fetches /api/v1/coins and routes each coin’s requests to that coin’s nodeUrl — so the cleaner model makes the visor the coin router while the wallet source needs no change: /api/v1/coins returns the registry (Skycoin as /coin/0, Bitcoin as /coin/1), and /coin/<index>/… proxies to that coin’s backend — the skycoin node over dmsg, or the in-visor electrum gateway. BTC stops being a special case and becomes just /coin/1. A new pkg/wallet/coins registry is the one Go source of truth for both the native HV and wasm shims. The wasm side is validated live, with /coin/0/api/v1/health reaching the real skycoin node over dmsg and returning a live blockchain head.

On the honesty front, 3501 feat(wasm-visor): fold a reworded disclaimer into the tour puts a concise, self-custody disclaimer behind a collapsible expander on the tour’s opening screen, covering both Skywire and the Skycoin wallet as open-source, as-is software — the replacement for the blocking modal skycoin-web used to show. The wording deliberately fixes the original modal’s two weak points: it drops “cryptographic tokens” (Skycoin is its own coin with its own chain and Coin Hours, not a token) and drops the reference to nonexistent Terms and Conditions, while keeping the honest no-warranty stance and adding the point that actually matters for a local wallet — you alone hold your keys and coins, and no one can recover them. 3506 chore(wallet): re-embed skycoin-web with reworded disclaimer + embedded onboarding-skip makes that live in the embedded wallet by vendor-bumping skycoin to 5c9484d3 and re-embedding: the reworded disclaimer, plus a skip of the language and disclaimer modals when skycoin-web is served under the /wallet/ base href, since the visor surfaces its own combined disclaimer in the tour. That re-embed came out clean — 11 files of real content, no spurious permission churn — thanks to 3503 chore(make): normalize file modes in embed-wallet, which chmods the embedded tree after the cp -a copy so a re-embed only diffs on real content rather than on the vendored 755 exec bit on files like skycoin-lite.wasm.

Skywire: Diagnostics and Housekeeping

3507 feat(dmsg): surface the dmsg-server connection protocol makes visible something that was recorded internally but never exposed — the carrier a session was dialed over: tcp, ws, wss, webtransport, or quic. New Carrier()/Protocol() accessors on the dmsg session, a parallel sessions field on the visor’s dmsg info, and a ProtocolLabel helper that distinguishes plain ws:// from TLS wss:// by scheme feed the CLI (dmsg sessions now prints the protocol next to each server), the RPC/HTTP API, and the wasm visor’s self-summary. It’s a useful diagnostic on its own — a native visor connects over tcp, a browser tab over wss or webtransport, and now you can tell at a glance — and it’s groundwork for the wasm onboarding tour explaining dmsg-servers-as-anonymous-relay.

The rest is CI and dependency hygiene, most of it aimed at getting develop’s checks green so feature PRs stop needing admin merges. 3504 chore: fix pre-existing CI lint failures greens the golangci-lint and tslint jobs with a //nolint:errcheck on a test body read and a couple of Angular member-ordering and statement-spacing fixes, no behavior change. 3509 chore(lint): de-deprecate httprate.LimitByIP + gofmt addrresolver clears two more pre-existing lint errors — replacing the deprecated httprate.LimitByIP with an explicit LimitBy plus a local key func that inlines the old per-RemoteAddr, IPv6-to-/64 behavior identically, and a stray gofmt fix — unblocking the other open PRs. 3505 chore: update deps, bump CI Go 1.26.5 is the routine make update-dep refresh (the same one that pulled in the proxyproto surprise fixed by #3510), bumping CI Go from 1.26.4 to 1.26.5 and regenerating the README dependency graph and LOC table, now around 450k lines reflecting this cycle’s new packages. And 3508 build(deps-dev): bump websocket-driver from 0.7.4 to 0.7.5 reproduces a Dependabot lockfile bump as a clean authored commit on the fork, pulling in the WS framing layer’s defensive max-length hardening.