Skip to content

feat(wasmhv)+dmsg: TinyGo wasm hypervisor (net/http-free) + browser p2p transports - #6

Closed
0pcom wants to merge 54 commits into
developfrom
feat/wasmhv-tinygo
Closed

feat(wasmhv)+dmsg: TinyGo wasm hypervisor (net/http-free) + browser p2p transports#6
0pcom wants to merge 54 commits into
developfrom
feat/wasmhv-tinygo

Conversation

@0pcom

@0pcom 0pcom commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Makes the wasm hypervisor (cmd/dmsg-wasm) compile under TinyGo, removing net/http from its graph entirely, and adds two more browser transports (WebTransport, WebRTC). Result: a 6.5 MB wasm (vs 21.6 MB standard-Go; 2.4 MB gzipped) — small enough to embed by default.

Everything new is build-tag-gated to wasm/tinygo; no production (native) build is affected. Verified across native go build ./..., std-Go js/wasm, and tinygo build -target wasm.

Why

net/http is broken on the TinyGo js target (roundtrip_js.go) and can't be forked (stdlib paths aren't redirectable), so it's removed from the cmd/dmsg-wasm graph. It was pulled by three things, each given a native/tinygo split (native keeps net/http; tinygo gets a net/http-free equivalent):

  1. net/rpcpkg/wasmhv/gobrpc.go, a client speaking the exact net/rpc gob wire protocol (tested against a real net/rpc server); noise RPCClientDialer split to rpcdialer.go (!tinygo).
  2. discovery over dmsg (keystone) → dmsgclient.dmsgDiscClient speaks HTTP/1.1 straight over a dmsg stream (httpdmsg.go — Content-Length/chunked/Connection:close framing, tested vs a real net/http server). seeded.go's upgrade splits native/tinygo; the net/http-free fallbackDiscClient is extracted to fallback_disc.go; net/http+cobra CLI files move behind !tinygo.
  3. coder/websocket (pulls net/http via Dial's *http.Response) → ws_js_tinygo.go dials the browser-native WebSocket via syscall/js as a deadline-aware net.Conn. ServeWS split to ws_server.go (!tinygo). Only tinygo && js && wasm is affected — native, std-Go wasm, tinygo+wasip1/IoT keep coder/websocket.

Browser transports

  • WebSocket carrier — ws_js_tinygo.go.
  • WebTransport carrier — wt_js_tinygo.go, CA-free via serverCertificateHashes cert-hash pin; mirrors native wt.go.
  • WebRTC DataChannel (first true p2p) — cmd/dmsg-wasm/webrtc_js.go; dmsg is the signaling plane (offer/answer/ICE over a dmsg stream), DataChannel → net.Conn. JS API webrtcDial/webrtcListen.

Validation

  • Compiles: native, std-Go wasm, TinyGo wasm. Unit tests for the gob-RPC + HTTP/1.1 framing pass; lint/fmt clean.
  • Not yet browser-runtime-validated. make tinygo-dmsg-wasm && go run cmd/dmsg-wasm/serve.go builds + serves the harness (cmd/dmsg-wasm/index.html) for manual validation against a live dmsg server — the gate before building on the WebRTC/WebTransport paths.

Direction (design only, not implemented here)

docs/design/wasm-visor-p2p.md + cmd/wasm-visor-probe map how much of the visor can port to wasm: pkg/routing + pkg/visor/visorconfig already compile under TinyGo; the rest is blocked by a small recurring set (quic-go raw-socket networks, net/http discovery clients, net/rpc, os/exec). Includes a phased plan + an app-feasibility analysis ("reachability ≠ listening"; a tab is a routing/publishing participant — a consumer of infra apps, not a provider).

🤖 Generated with Claude Code

0pcom added 30 commits June 18, 2026 15:39
… can't wedge the RPC (skycoin#3166)

initAddressResolver runs early and tr (transport) + cli (RPC) depend on ar, so
ANY blocking call here stalls the entire visor — including the RPC, leaving the
operator unable to access the visor at all. The STUN-fallback path did an
UNBOUNDED <-v.stun.ready; when STUN is slow (e.g. during a deployment-side
dmsg/AR hiccup) the visor hung for minutes with no RPC. Bound it to 20s and
proceed without a STUN-derived public IP on timeout (same as the existing
'could not determine public IP' path). Also nil-guard v.stun.client.

NOTE: partial robustness fix — the proper fix is to decouple the RPC + resolver
init from ar's network-dependent steps entirely (same anti-pattern as skycoin#3143:
a network dependency gating a critical surface) so a flaky deployment can never
take operator control of a visor offline. Tracked as a follow-up.
…g/health identity (skycoin#3167)

- Colorized /skywire.log was rendering every line one uniform dim color; rewrite
  colorizeLine to split per element matching logrus's console scheme: dim-gray
  timestamp, level-colored LEVEL + [module], BRIGHT message (the message is now
  the brightest element, as in the terminal), dimmed key=value fields.
  Non-matching lines pass through escaped; ?raw=1 untouched; all html.EscapeString
  -escaped (XSS test green).
- Identity was redundantly shown in both places. Landing page now shows the
  PUBLIC KEY only; /health carries dmsg_address only (public_key omitted via
  omitempty). Per operator preference.
…IP) (skycoin#3169)

* fix(visor): decouple RPC from AR public-IP discovery — async SetPublicIP

The RPC control plane (cli) transitively depends on ar via cli→tr→ar, and the
init module runner has no per-module timeout. initAddressResolver determined
the visor's public IP synchronously (dmsg LookupIPGeo ≤10s + STUN ≤20s + the
Phase-2c family lookup) before returning, so a slow-dmsg boot left ~30s of
data-plane IP discovery wedging the entire control plane — the operator could
not reach the visor over RPC for the better part of a minute (on top of the
30s dmsg-connect wait in initDmsgHTTP).

Construct the AR client immediately with an empty public IP and determine the
IP in a background goroutine (resolvePublicIPForAR) that pushes it in via a new
APIClient.SetPublicIP. ar now completes in milliseconds, so tr→cli(RPC) bind
promptly regardless of dmsg/STUN state.

The data plane stays correct: BindSTCPR does a bounded wait (35s) on the
asynchronously-set IP before registering, so the first bind still carries the
public IP in the common case; if it times out the bind proceeds with what the
AR observes from the source IP and the stcpr re-registration loop (90s) carries
the real IP once it lands. All clientPublicIP/v6 access is now guarded by an
RWMutex (concurrent SetPublicIP vs bind reads).

Also bound the previously-unbounded <-v.stun.ready in initDiscovery (same wedge
class on the launcher path) and nil-guard v.stun.client there.

Validated: build, vet, race tests (addrresolver, transport/network) green.

* chore(lint): justify gosec G118 nolint on async AR IP goroutine + spelling
…n#3168)

* fix(dmsg): retry entry-publish on failure + bound the attempt

updateClientEntryLoop unconditionally rearmed its timer at
c.updateInterval (default 1 min, 5 min under dmsg.DefaultConfig)
whether the previous updateClientEntry returned success or error,
AND called updateClientEntry with the long-lived service ctx — no
per-attempt deadline. Two pathologies follow:

  1. A transient publish failure leaves the service unregistered for
     a full updateInterval. The first published entry is the only one
     visors can dial against, so for the duration of that window the
     service is effectively offline.

  2. If the underlying PUT call itself doesn't error out cleanly
     (e.g. a dmsg-HTTP RoundTrip whose DialStream retries push the
     overall call past several internal deadlines without surfacing
     a terminal error), the loop body BLOCKS in updateClientEntry
     indefinitely. No log, no retry, no recovery.

Both bite hardest on a fresh dmsg-only deployment service when the
service and dmsg-discovery cold-start together via autopull: the
service's first PutEntry races dmsg-discovery's own outbound dmsg
sessions establishing, hits "i/o deadline reached" repeatedly, and
then sits silent for 5+ minutes (often forever). Verified on
transport-setup-4 with discovery_dmsg-only config after skycoin#3161 + skycoin#3157
landed: the entry-publish path is correct, only the retry cadence and
attempt-bounding are missing.

Two narrowly-scoped changes:

  - Wrap each updateClientEntry call with context.WithTimeout
    (entryUpdateAttemptTimeout = 30s). A genuinely stuck PUT errors
    out at the call boundary instead of wedging the loop, and the
    caller's ctx still drives shutdown.

  - On error from updateClientEntry, rearm the timer with
    exponential backoff (entryFailureBackoff: 1s, 2s, 4s, 8s, 16s,
    clamped at entryUpdateMaxBackoff = 30s) instead of
    c.updateInterval. On success, reset to c.updateInterval as
    before. consecutiveFailures resets on the first success.

No semantic change on the happy path: a successful update still
schedules the next attempt at updateInterval. The change is only in
how aggressively the loop recovers from transient failures and in
that a stuck attempt eventually surfaces an error rather than
blocking the goroutine.

* fix(lint): gosec G104 on visorlog_html.go Flush() calls

Two pre-existing unhandled-error sites on develop block skycoin#3168's CI:
both *bufio.Writer.Flush() calls at the body-write and trailer-write
positions return an error that was ignored. Silence with `_ =` +
//nolint:errcheck; the surrounding logic already drains the source
reader to EOF and the response is best-effort streamed, so a flush
error has no caller worth surfacing it to.

* fix(lint): gosec G104 — use explicit discard for io.WriteString

//nolint:errcheck silences errcheck but not gosec G104. The two
io.WriteString calls in renderVisorLogHTML need an explicit
`_, _ =` discard (or a real error handler); since the surrounding
logic streams the response best-effort and a write error means the
client is gone — caller has nothing to recover with — explicit
discard is the right call.

Replaces the //nolint:errcheck directive form so both linters
agree.

* fix(lint): satisfy both errcheck (check-blank:true) and gosec G104

The repo's .golangci.yml sets errcheck.check-blank: true, which makes
errcheck flag even blank-identifier discards (`_, _ = ...`). So the
prior `_, _ = io.WriteString(...)` (which silenced gosec G104) is
still flagged by errcheck. Conversely, the original
`io.WriteString(...) //nolint:errcheck` silenced errcheck but gosec
G104 ignored the //nolint directive.

The combination that satisfies both: `_, _ =` discard (gosec sees
the explicit choice and stops flagging) + `//nolint:errcheck`
(suppresses errcheck despite check-blank:true). Inline comment on
the multi-line WriteString lives on its own line per the style the
linter accepts there.
…ore init (skycoin#3170)

* fix(visor): install log broadcaster before module init so --verbose works during startup

v.logBcast was set at run() (visor.go:542) only AFTER NewVisor returns, but the
RPC 'cli' module inits INSIDE NewVisor (InitConcurrent). A 'cli ... --verbose'
log stream connecting during startup found v.logBcast nil → 'log broadcaster not
available'. Thread the (already logger-hooked) broadcaster into NewVisor and set
v.logBcast before InitConcurrent. Single caller updated; SubscribeLogs is
nil-safe so the nil-default (tests) is unchanged.

* fix(visor): nil-guard runtime-log RPCs + wire logstore before init (fixes startup segfault)

cli visor log / --follow (RuntimeLogs / RuntimeLogsSince) dereferenced
v.logstore, which run() wired via SetLogstore only AFTER NewVisor returns — but
the RPC binds DURING module init inside NewVisor. A log query in that window hit
a nil v.logstore and SIGSEGV'd the whole visor (panic at api_visor.go:432). A
long-lived 'cli visor log --follow' reconnects the instant RPC binds on every
restart, so it reliably lands in that window and crash-loops a freshly-started
visor — and skycoin#3169 (faster RPC bind) widened the window.

- Nil-guard RuntimeLogs (return "[]") and RuntimeLogsSince (empty delta): a CLI
  log query must never crash the visor.
- Wire the logstore (and the already-moved broadcaster) into NewVisor BEFORE
  module init, eliminating the window so startup log queries return real data.
- Regression test: nil-logstore RuntimeLogs/RuntimeLogsSince do not panic.

Same init-order class as the broadcaster fix in the parent commit.
…ap seed (skycoin#3171)

The RSN (pkg/router/setupnode.go) and transport-setup
(pkg/transport-setup/api/api.go) post-skycoin#3161 single-client dmsg
bootstrap built its direct discovery purely from conf.Dmsg.Servers.
With an empty conf.Dmsg.Servers, directDisc held only synthetic
client entries for the local PK and the dmsg-disc PK — no server
addresses to dial — and the RegisteringFallbackDiscClient's HTTP-
over-dmsg path required dmsg sessions to make HTTP calls, which it
can't form without knowing a server: bootstrap deadlock, never
hits dmsgC.Ready().

Mirror dmsgsrv.buildTransitDmsg's merge shape: build the seed-
server set from conf.Dmsg.Servers ∪ dmsg.Prod.DmsgServers, deduped
by Static PK, operator-supplied entries first. A deployment may
omit conf.Dmsg.Servers entirely and still cold-start from the
embedded Prod set, matching the fallback behavior dmsg-server and
the visor have always had.

Same null/Server-nil guard moves from the SeedEntryCache loops
into a shared addServer closure.
…wedge the client (skycoin#3172)

The first session's setSessionCallback was calling updateClientEntry
SYNCHRONOUSLY on the session-dial goroutine with an UNBOUNDED context
and only closing c.ready on success. When the publish stalled — a
dmsg-only deployment service (skycoin#3157, skycoin#3168) whose only established
session couldn't reach the discovery PK — the entire client wedged:

  - first setSessionCallback blocks → dialSession never returns →
    the Serve loop's EnsureSession chain freezes;
  - subsequent session callbacks (ready never closed) re-enter the
    same default branch and block on httpClient.updateMux which the
    first call holds;
  - updateClientEntryLoop's runUpdate (with the skycoin#3168 30 s attempt
    timeout) ALSO blocks on updateMux because mutex acquisition does
    NOT honor context — so the loop's retry never fires either.

Production symptom: TPS-4/5/6 + SD logged "Updating entry" once at
startup and went silent for hours after the .Discovery http URL was
stripped from their configs. No POST ever reached dmsg-discovery.

Fix has two layers:

1. setSessionCallback runs the first-session publish on its OWN
   goroutine with the same per-attempt timeout the entry loop uses,
   waits up to that bound, then closes c.ready regardless of result.
   The publish goroutine continues in the background; the
   updateClientEntryLoop retries on failure with exponential backoff.

2. dmsghttp.HTTPTransport.do installs a ctx watcher that closes the
   underlying stream when req.Context() is canceled, so req.Write
   and http.ReadResponse don't outlive their caller's timeout. Without
   this the dmsg.Stream's own deadlines (write: none after handshake,
   read: StreamIdleTimeout = 2 min) were the only bound and the skycoin#3168
   per-attempt ctx had no effect on a stuck stream.

Together the two changes break the cascade: the per-attempt ctx
actually fires through the stream, the publish goroutine returns,
the mutex releases, and the loop keeps retrying on the standard
exponential backoff.
…ser) (skycoin#3173)

The colorized /skywire.log view was a one-shot render that stopped at EOF. Make
it a never-ending chunked response: write the backlog, then tail the file,
flushing each newly-appended line until the client disconnects (detected via
the request context). No Content-Length → Go uses chunked transfer-encoding, so
each Flush reaches the browser over the dmsg-HTTP tunnel immediately — a live
tail -f in the browser.

- Flush per-line once caught up to EOF (low latency); batch-flush the initial
  backlog (every 256 lines) to avoid a chunk per line.
- Detect log rotation/truncation (path resolves to a file smaller than our read
  offset) and re-open the new file, emitting an inline '--- log rotated ---'.
- Tiny inline auto-scroll script (progressive enhancement): pins to the newest
  line while the reader is at the bottom, releases when they scroll up.
- ?raw=1 (one-shot plain text) and ?follow=1 (filtered plain-text stream) are
  unchanged.

Test: TestRenderVisorLogHTMLStreams renders backlog + an append-after-start,
returns promptly on disconnect, emits no closing document. Race-clean.
…skycoin#3174)

Stops route-finder, address-resolver, transport-discovery, service-
discovery, uptime-tracker, and config-bootstrapper from making plain-
HTTP calls to dmsg-discovery. They now route all discovery RPC over
dmsg-HTTP via their bootstrap dmsg client.

svcmode.Config gets a new DmsgDiscoveryDmsg field (form
`dmsg://<PK>:<port>`). When set, cmdutil.BootstrapDmsg wires the disc-
fallback http.Client's Transport to dmsghttp.MakeHTTPTransport(ctx,
dmsgC) between dmsg.NewClient and Serve — same single-client wiring
post-skycoin#3161 setup-node/transport-setup use to dodge the dual-client
self-eviction storm. dmsgDisc (HTTP URL) is ignored entirely in this
mode; the background server refresh also uses dmsg-HTTP exclusively
with no plain-HTTP fallback — a failed refresh is preferable to a
silent fallback once the operator has opted out of HTTP.

Each of RF/AR/TPD/SD now reads cfg.Dmsg.DiscoveryDmsg and defaults to
dmsg.DiscAddr(false) (the embedded dmsg-PK URL from
deployment.Prod.DmsgDiscoveryDmsg) when omitted. The plain-HTTP
discovery fallback (`if dmsgDisc == "" { dmsgDisc = dmsg.DiscURL(false) }`
and the TPD variant against deployment.Prod.DmsgDiscovery) is removed
— cfg.Dmsg.Discovery is now an ignored field on these service configs,
preserved only for backward-compat JSON parsing.

uptime-tracker and config-bootstrapper get a new --dmsg-disc-dmsg flag
(default dmsg.DiscAddr(false)). The existing --dmsg-disc flag no
longer defaults to the HTTP URL; operators who still need plain-HTTP
discovery must set it explicitly. Both flags are passed through; when
--dmsg-disc-dmsg is non-empty (which is the new default) the binary
runs dmsg-only.

cmdutil.BootstrapDmsg's signature gains a dmsgDiscoveryDmsg parameter.
All three existing non-svcmode callers (skywire-cli config gen and the
two log commands) pass "" — preserving HTTP behavior for the CLI.

After this lands, deployment configs may drop their `discovery` HTTP
URLs entirely; the embedded deployment config supplies the dmsg-PK URL
default. The HTTP URL fields remain in the embedded config as
reference data only — the deployment binaries never call them.
…sions=1 default (skycoin#3175)

setupDmsgC was calling dmsg.DefaultConfig() which hardcodes
MinSessions=1. The Serve loop's

	if MinSessions != 0 && SessionCount() >= MinSessions { wait <-errCh }

gate fires the moment the FIRST seeded server's session establishes,
leaving the other 5 (including the only one definitely connected to
the discovery PK in single-host deployments) un-dialed. For the
registering variant TPS uses, the entry never publishes because
PutEntry's DialStream can't find a session that forwards to the
discovery PK — dmsg error 202 forever.

RSN's pkg/router/setupnode.go:83 already reads
conf.Dmsg.SessionsCount via
`&dmsg.Config{MinSessions: conf.Dmsg.SessionsCount}`; this aligns
TPS with the same shape.

Production symptom: post-skycoin#3171/skycoin#3172 deploy of TPS-4/5/6 against
DiscoveryDmsg only — Serve loop took the first random seed server,
parked, and the 5-minute publish-then-silent pattern recurred on the
new binary even though both the deadlock and ctx-watcher fixes had
landed.

Concretely: sessions_count: 6 in tps.json was silently ignored.
… clients (skycoin#3177)

Picks up the lingering plain-HTTP discovery paths from the post-skycoin#3163
follow-up list and converts each to either dmsg-HTTP or the
embedded-prod short-circuit.

### log/log.go + log/dmsghttp_client.go
BootstrapDmsg calls now route the per-PK Entry() fallback over dmsg-
HTTP by default via deployment.Prod.DmsgDiscoveryDmsg. A custom
--dmsg-disc http://… still works (legacy passthrough) but the prod
default no longer egresses plain HTTP at bootstrap. dmsgDiscArgs in
log/root.go centralizes the resolution.

### route/trace.go
Replaces `rfclient.NewHTTP(traceRfURL, ..., &http.Client{Timeout},
nil)` with a POST /routes over the local visor's DmsgHTTP RPC when
traceRfURL has a dmsg twin in DmsgURLForHTTP (production default).
Custom --rf URLs without a known dmsg twin still fall back to the
plain rfclient. Re-uses the visor's already-established dmsg
sessions — no per-call dmsg bootstrap.

### svc/health.go
resolveServerEntry now consults deployment.Prod.DmsgServers BEFORE
the plain-HTTP discovery all_servers fetch. Every prod dmsg-server
PK resolves locally; the HTTP path remains for the rare custom case.

### reward/lookup.go, ptyfs/mount_linux.go, dmsg/curl.go
Each had its own ad-hoc `dmsg.NewClient(pk, sk, disc.NewHTTP(...))`
ephemeral bootstrap with MinSessions=1 and a plain-HTTP discovery
fallback. Replaced with cmdutil.BootstrapDmsg using the embedded
prod server set + dmsg-only Entry() fallback (post-skycoin#3174 plumbing).
Same Ready() / 30 s timeout shape, no behavior change for callers.
… Entry recursion (skycoin#3178)

skycoin#3174 wired the dmsg-only disc fallback through dmsg-HTTP but did not
add the dmsg-discovery's own PK to the direct.Client synthetic seed
map. With dmsg-only routing in steady state:

  - dmsg.Client.DialStream(<remote-pk>) — for any unfamiliar PK or for
    the dmsg-discovery itself — calls discClient.Entry(<dmsg-disc-pk>)
    to discover an entry first.
  - directDClient doesn't know the dmsg-disc PK, falls through to the
    dmsg-HTTP fallback.
  - dmsg-HTTP's request needs to dial the dmsg-disc PK, which loops
    back through discClient.Entry(<dmsg-disc-pk>) again.

The dmsg client's session cache eventually short-circuits the second
roundtrip, but every fresh caller-site retriggers the lookup. Observed
in prod after skycoin#3174 landed: route-finder/conf-service/uptime-tracker
in tight loops at ~300-600 disc lookups/s, host-wide CPU at ~315%
spread across these services.

Seed the dmsg-disc PK into directDClient as a synthetic client entry
delegating to every bootstrap server, so Entry(<dmsg-disc-pk>) short-
circuits at the direct layer. Same trick setup-node and transport-
setup already use post-skycoin#3161 via dmsgServicePKsFromConf /
dmsgServicePKs.
…ot after the inner for-loop completes (skycoin#3179)

The Serve loop's per-iteration background-loop start lines —
updateClientEntryLoop, pingSessionsLoop, porterReapLoop — sit AFTER
the inner for-loop that iterates seed entries. That was correct under
the original assumption that EnsureSession returns quickly: iterate
all entries, hit MinSessions on the last few, fall through to start
the loops, then park on <-errCh.

Post-skycoin#3172, setSessionCallback runs the first-session publish
synchronously and blocks for up to entryUpdateAttemptTimeout (30 s).
Phase 3 of THAT publish's own DialStream recursively dials extra
sessions via EnsureAndObtainSession, pushing SessionCount past
MinSessions before the original EnsureSession returns. When it
finally does, the next inner-for iteration trips

	if ce.conf.MinSessions != 0 && ce.SessionCount() >= ce.conf.MinSessions {
		select { case <-ce.done: ... case err := <-ce.errCh: ... }
	}

and parks on <-errCh — for the rest of the process's lifetime if
sessions are stable. The for-loop never completes, the lines below
never run, updateClientEntryLoop never starts, and the entry-publish
retry path never fires. The service stays unregistered until the
very first session dies.

Production symptom (prod02 TPS-4 today, post-skycoin#3174/skycoin#3175 deploy):
- 23:00:28 first 'Updating entry.' debug (setSessionCallback's
  initial publish goroutine)
- 23:00:58 'Initial client entry update did not complete within
  bound; closing Ready and letting the loop retry.' fires (the
  skycoin#3172 30 s timeout)
- 23:00:58 'client entry update failed for discovery; will retry
  next tick' debug (PutEntry returned dmsg error 202)
- then 12+ minutes of silence — no 'Failed to update discovery
  entry (tick).' warn ever, because the loop the comment refers to
  was never started. 9 sessions established, all healthy. Visor
  health checks succeed (via dialViaConnectedServers fallback).
  TPS-4 just never publishes its entry.

Fix moves the same updateEntryLoopOnce.Do / pingLoopOnce.Do /
porterReapLoopOnce.Do block into the inner for-loop's success
branch, so they fire on the FIRST successful EnsureSession instead
of after the whole iteration completes. sync.Once makes the
post-iteration block a no-op when this path fired. The
post-iteration block is kept as a defensive net for the case where
every entry's EnsureSession failed and we fell through to the
outer-select retry path.
…skycoin#3180)

The operator 'skywire svc setup-node health <pk>' subcommand
bootstrapped its ephemeral dmsg client with disc.NewHTTP against
deployment.Prod.DmsgDiscovery (plain HTTP) and dialed dmsg-discovery
directly to look up the remote setup-node entry. With Caddy retired
from the deploy-services HTTP path that lookup now 404s; the
subcommand becomes unusable in prod even though the operator and the
target setup-node are both fine.

Replace with cmdutil.BootstrapDmsg in dmsg-only mode: pass
dmsg.Prod.DmsgServers as the embedded seed, leave dmsgDisc empty,
set dmsgDiscoveryDmsg to dmsg.DiscAddr(false). Mirrors the path the
deploy services themselves take post-skycoin#3174 + the disc-seed fix in
this PR — no plain-HTTP egress.

Drops net/http and pkg/dmsg/disc imports (no longer used directly
in this file).
…tream must not hang the hypervisor (skycoin#3181)

* fix(hypervisor): start tpviz data-population async so it can't block the hypervisor servers

NewHypervisor called hv.tpvizServer.Start() SYNCHRONOUSLY. Start() fetches
geoip and refreshes its TPD/SD caches over the (prod, plain-HTTP) deployment
URLs (tpviz.DefaultConfig: TPDURL=deployment.Prod.TransportDiscovery). When the
deployment HTTP front door is closed/slow (e.g. Caddy returning 404 for the
deployment services), those blocking fetches stall NewHypervisor →
initHypervisor → and the hypervisor never reaches hv.Enable(ctx), so its HTTP
(:http_addr) and DMSG-RPC (:dmsg_port) servers never start. Managed visors then
fail to connect with 'dmsg error 306 - request has no associated listener', and
'cli visor hv status' shows disabled despite a valid, enabled config.

Run tpvizServer.Start() in a goroutine: the hypervisor control plane comes up
immediately; tpviz serves empty/stale until its first refresh completes. Latent
since the unconditional-tpviz change (skycoin#2474); surfaced when the deployment
services' HTTP was retired in favor of dmsg-only.

* fix(visor): connect to uptime-tracker async so an unreachable UT can't hang init

initUptimeTracker called utclient.NewHTTP synchronously. NewHTTP runs a
blocking auth handshake (GET /security/nonces) with INFINITE exponential-backoff
retry. When the UT service is unreachable — e.g. it is being deprecated/torn
down and its dmsg endpoint returns 'dmsg error 202 - cannot connect to delegated
server' — this never returns, so initUptimeTracker hangs forever. That stalls
the whole 'visor' module from completing, which blocks every module that
depends on it. Most visibly: the 'hypervisor' module (maker(hv, …, &vis)) waits
on vis, so it never runs hv.Enable() → the hypervisor's HTTP (:http_addr) and
DMSG-RPC (:dmsg_port) servers never start → managed/SBC visors fail to connect
with 'dmsg error 306 - request has no associated listener', and 'hv status'
shows disabled despite a valid, enabled hypervisor config.

Move the entire UT connect + TPD-heartbeat-client + heartbeat loop into a
background goroutine (using v.ctx, not the init ctx). initUptimeTracker returns
immediately; UT is non-critical (transport re-registration also records uptime)
and must never gate startup. Surfaced fleet-wide as the standalone UT is retired
and the deployment HTTP front door closed.
…v ls (also fixes tpviz restart panic) (skycoin#3182)

* feat(hypervisor): separate web-UI serving from the RPC/managed-visor surface

`hv disable` tore down everything: the DMSG-RPC listener (managed visors connect
here), the managed-visor tracking, AND the web UI — so it also killed CLI access
(`hv ls` gates on IsEnabled). You couldn't shrink the public web attack surface
while keeping secure CLI access to connected visors.

Split the two concerns:
- The hypervisor's DMSG-RPC listener + managed-visor tracking stay tied to
  enable/disable (`hv enable`/`hv disable`, unchanged) — the secure,
  whitelist-gated surface that backs `hv ls`.
- The web UI (HTTP :http_addr) is now an independent toggle, gated by a new
  `hypervisor.ui_disable` config field (default false = served; backward-compat).
  New `hv ui enable` / `hv ui disable` (with -w to persist) flip ONLY the HTTP
  server; the DMSG-RPC + `hv ls` keep working.

Implementation: extract startUI/stopUI from Enable/Disable; add a uiServing flag
separate from enabled; Enable starts the UI only when !ui_disable; tpviz (which
only backs UI routes) now starts/stops with the UI. RPC: EnableHypervisorUI/
DisableHypervisorUI/IsHypervisorUIServing on the API + RPC server/client + proxy/
mock. Build + vet + lint green.

* feat(dmsg): track + expose no-listener port hits (cli dmsg port-hits)

Companion to the hv-ui split: when a peer opens a dmsg stream to a local port
with no listener bound, the session serve loop already rejected it with
ErrReqNoListener (dmsg 306) and logged src_pk/dst_port. Capture those into a
bounded (cap 256, evict-oldest), mutex-guarded tracker on EntityCommon keyed by
(src PK, dst port) → {count, first_seen, last_seen}.

Surfaced via Visor.DmsgPortHits() RPC and 'cli dmsg port-hits' (table or
--json). Lets an operator see who's trying to reach a service they aren't
serving — e.g. managed visors hitting a disabled hypervisor port (the exact
'306 - no associated listener' case from the hv-ui discussion), misconfigured
peers, or port scans — even with nothing listening to accept them.

Scope: no-listener/error hits only (not all accepted traffic). Unit test covers
coalesce/evict/order/nil-safety; build+vet+lint+race green.

* fix(hypervisor): clearer error when hv instance isn't ready vs not configured

'hv enable/ui enable/ui disable' returned 'hypervisor not configured — add
hypervisor section' whenever v.hvInstance was nil — INCLUDING when the config
DOES have a hypervisor section but the instance hasn't initialized yet (the
visor is still starting, or its init stalled because a dependency hung — e.g.
the uptime-tracker hang fixed in skycoin#3181). Operators with a valid hypervisor
config were sent off to add a section that already exists.

Disambiguate: only say 'not configured' when conf.Hypervisor == nil; otherwise
report 'configured but not initialized yet — the visor may still be starting up
(or its init stalled); check the visor log and retry'.

* feat(config): stop generating the deprecated uptime_tracker block

The standalone uptime-tracker service is deprecated — uptime is now
tracked by the discovery services (TPD et al.) and the service was
turned off. Generated visor configs no longer carry an `uptime_tracker`
block: MakeBaseConfig and the CLI `config gen` paths (dmsg-only, dual,
http; unified + legacy dmsghttp) no longer create or populate it, so
conf.UptimeTracker stays nil and initUptimeTracker skips it.

Removing the block exposed two unguarded conf.UptimeTracker.Addr derefs
that would nil-panic a visor booted without it:
  - pkg/visor/survey.go:84 (system_survey goroutine — crashed on boot)
  - cmd/skywire-cli/commands/survey/root.go (cli survey)
Both are now nil-guarded, matching the other readers (api_services,
embedded_dmsgweb, init_dmsg, init_apps) which already guard it.

Validated on the local hypervisor: clean boot against a UT-less config,
survey generated without panic, managed visors reconnected.
… / N visors (skycoin#3176)

* fix(e2e): make all e2e configs dmsg-only — strip every http:// service URL

The e2e deployment + visor configs still carried plain-HTTP service URLs
(discovery/transport_discovery/address_resolver/route_finder/uptime_tracker/
service_discovery) even though the network and the deploy-service binaries are
now dmsg-only (skycoin#3161/skycoin#3171/skycoin#3174). The setup-node/transport-setup, which build
their dmsg client from conf.Dmsg.DiscoveryDmsg, couldn't bootstrap with only
'discovery: http://…' set, deadlocking the shared 'skywire svc run' supervisor
→ 'deployment-services unhealthy' → linux e2e red on develop and every PR.

Convert ALL e2e configs to dmsg-only (zero http:// URLs), matching the live
network:
- Visor configs (visorA/B/C + -internal): drop dmsg.discovery,
  transport.discovery, transport.address_resolver, routing.route_finder,
  uptime_tracker.addr, launcher.service_discovery; set the corresponding
  *_dmsg fields (dmsg://<pk>:80, PKs from services-config.json). geoip → .
- setup-node.json / transport-setup.json: discovery_dmsg + servers (the e2e
  dmsg-server) + drop http discovery; setup-node transport_discovery → _dmsg;
  transport-setup sessions_count 0→1 (honored after skycoin#3175).
- services.json (tpd/rf/sd/ar): discovery_dmsg, drop http discovery
  (deploy-services dmsg-only via skycoin#3174).
- dmsg-server.json: discovery_dmsg (dmsgsrv is strict dmsg-only), drop http.
- services-config.json: drop the http_* discovery URLs in test+prod, keep _dmsg.

All PKs cross-checked against dmsg-server.json + services-config.json + derived
from the service SKs. Zero http:// remain; JSON valid; no new null fields.
Depends on skycoin#3171/skycoin#3172/skycoin#3174/skycoin#3175 (all merged except skycoin#3171/skycoin#3172) for the
binaries to bootstrap dmsg-only.

* fix(e2e): consolidate to one redis + drop deprecated uptime-tracker/postgres/network-monitor

Slim the e2e container set to '1 redis + 1 deployment-services + N visors':

- Five per-service redis containers (dmsgd/ar/sd/tpd/ut-redis) → a single
  'redis'. Each discovery service uses its own logical DB to preserve the
  keyspace isolation the separate instances gave (go-redis ParseURL honors
  the redis://host:6379/<N> DB selector):
    /0 dmsg-discovery  /1 transport-discovery + route-finder (shared — RF
    reads TPD's transport data)  /2 service-discovery  /3 address-resolver
- Drop the standalone uptime-tracker service + its postgres + ut-redis —
  uptime tracking is now integrated into every discovery service (TPD et al.),
  so 'skywire svc ut' is deprecated. Removed from compose, the e2e-run
  Makefile stages, env_test's expected-container list, the visor configs
  (uptime_tracker block) and services-config.json (uptime_tracker_dmsg).
- Drop network-monitor: already disabled (never started by e2e-run, excluded
  in env_test) and its 'svc nm' entrypoint monitored services over plain HTTP
  incl. the now-removed uptime-tracker. pkg/network-monitor code untouched.

e2e-run now brings up redis + deployment-services + visor-a/b/c (was 11
containers → 5, + the transient e2e-test runner). compose config valid;
integration test compiles; the only http:// left is the container-local
:6094/debug/service healthcheck.
…nto the router (skycoin#2607 stage-4a) (skycoin#3183)

The faithful-UDP stack (DatagramPacket wire type skycoin#2609, DatagramRouteGroup
skycoin#2610, per-datagram AEAD skycoin#2612, UDPBridge/appnet API skycoin#2613/skycoin#2614) shipped its
components but the router-side dispatch was never wired: a DatagramPacket
arriving off a transport fell through handleTransportPacket's switch to
ErrUnknownPacketType and was dropped, so the unit-tested DatagramRouteGroup.Handle
was unreachable in a running visor.

This wires the receive + relay path:
  - router gains an rgsDatagrams map (keyed like rgsNs) + datagramRouteGroup /
    setDatagramRouteGroup / removeDatagramRouteGroup accessors. Datagram groups
    live in their own map so a DatagramPacket is never confused with a reliable
    RouteGroup on the same descriptor.
  - handleTransportPacket dispatches DatagramPacket to handleDatagramPacket:
    intermediary/forward rules relay to the next hop; a consume rule delivers to
    the registered DatagramRouteGroup.Handle. No parking — a datagram that races
    route-group registration is dropped (faithful-UDP loss tolerance), unlike the
    reliable path which parks handshake frames. A closed group de-registers itself.
  - forwardPacket relays DatagramPacket: re-stamp the next-hop route ID, pass the
    opaque AEAD-sealed payload through unchanged (the seal is end-to-end;
    intermediaries never decrypt).

Inert until route-setup constructs+registers datagram groups (stage-4b) — nothing
emits a DatagramPacket yet, so production behavior is unchanged. New
datagram_dispatch_test.go drives real DatagramPackets through handleTransportPacket
(deliver / drop-when-unregistered / unknown-route-errors / closed-group-deregisters).
…tup (skycoin#2607 stage-4b) (skycoin#3184)

* feat(router): datagram AEAD master-key derivation from Noise ChannelBinding (skycoin#2607 stage-4b foundation)

First piece of the datagram route-setup integration, per the chosen key-exchange
design: the datagram sibling of a route keys itself off the reliable route's
already-completed Noise session instead of running a second handshake.

  - dmsg/noise: export Noise.ChannelBinding() — the handshake hash, identical on
    both peers and bound to the full transcript (incl. PQ-hybrid payload). Already
    used internally for the hybrid binding; now reachable to key a sibling channel.
  - router: deriveDatagramMasterKey(channelBinding) HKDF-expands the binding into
    the 32-byte master key NewDatagramCipher consumes, domain-separated
    ("skywire-datagram-master-v1") from the per-direction subkey labels. Both peers
    derive an identical key because the binding is identical.

Pure + unit-tested (deterministic across peers, distinct per session, empty-binding
rejected, and an end-to-end seal/open round-trip through NewDatagramCipher). Not yet
wired into route setup — that (threading the binding out of EncryptConn, building +
registering the datagram sibling on both ends, app DialPacket + UDPBridge) is the
remainder of stage-4b.

* feat(router): build the faithful-UDP datagram sibling during route setup (skycoin#2607 stage-4b)

With dispatch wired (stage-4a, skycoin#3183), route setup can now construct the
DatagramRouteGroup so datagrams actually flow. Design: a datagram route is NOT a
separate route — the reliable route is set up as usual (Noise KK handshake →
RouteGroup), and when both ends locally intend datagram mode, each additionally
builds a datagram sibling over the SAME rules/transport, keyed off the reliable
session's Noise ChannelBinding (no second handshake).

Key exchange (chosen approach — one-time Noise → HKDF):
  - dmsg/noise: export Noise.ChannelBinding() + delegate through ReadWriter/Conn,
    so network.EncryptConn's returned conn exposes the session binding.
  - router.deriveDatagramMasterKey(binding): HKDF-SHA256 the binding into the
    32-byte master both peers feed to NewDatagramCipher (domain-separated from the
    per-direction subkey labels). Identical binding on both ends → identical key.

On-demand by LOCAL intent (no wire negotiation — see the design discussion):
  - dial side: DialOptions.Datagram (set by the DialPacket / forwarded-UDP dialer);
  - accept side: RegisterDatagramPort marks the local serving port.
  saveRouteGroupRules gains a `datagram bool`; on the encrypt path it calls
  buildDatagramSibling, which derives the master, builds the out/in cipher pair
  (AAD binds to the receiver's PK; verified by a seal→dispatch→open round-trip
  test), and registers the sibling. Best-effort: any failure logs + skips, never
  touching the reliable route. Auto-every-route was rejected (per-route memory +
  attack-surface tax for a niche feature).

Lifetime: the sibling has no keep-alive of its own (a receive-only group never
writes), so it's coupled to the reliable route — closeDatagramSibling reaps it
from the close-packet path, removeRouteGroupOfRule, and the GC sweep (+ a
defensive self-closed sweep).

Still inert end to end until stage-4c wires SkywireNetworker.PacketNetworker +
the forwarded-ports UDP listener loop (NewUDPBridge) + RegisterDatagramPort.
Tests: master-key determinism/interop, port registry, and a full
seal→handleTransportPacket→AEAD-open→ReadFrom round-trip. Build/vet/lint green;
full router suite passes.
…skycoin#2607 stage-4c) (skycoin#3185)

Completes faithful UDP: with dispatch (4a) + route-setup sibling construction (4b)
in place, this wires the two endpoints so datagrams flow app-to-app.

Client (dial) path:
  - router.DialRoutesDatagram: dial a normal route with Datagram intent, then
    return both the reliable conn (kept alive — closing it tears the route down)
    and the registered DatagramRouteGroup sibling. Added to the Router interface
    (+ MockRouter).
  - appnet.SkywireNetworker.DialPacketContext (implements PacketNetworker): dials
    via DialRoutesDatagram and returns a skywirePacketConn — the sibling wrapped
    so Close also tears down the reliable route and frees the local port. This
    makes appnet.DialPacket functional for skynet addrs (was
    ErrPacketNetworkerNotSupported).

Server (accept) path:
  - router gains an accept channel: buildDatagramSibling offers each accept-side
    sibling (Initiator=false) to AcceptDatagram along with the route's local port.
    Register/UnregisterDatagramPort + AcceptDatagram added to the Router interface.
  - visor serveUDPForwards accept loop (started from initSkynetForwardPorts) drains
    accepted siblings and, per the forwarded_ports.udp registry, bridges each to
    its local UDP service via NewUDPBridge (SetLocalPeer = the service; poll
    IsAlive to reap on route close).
  - RegisterForwardedPort(UDP) → RegisterDatagramPort; DeregisterTCPPort(UDP) →
    UnregisterDatagramPort; startup restores datagram intent for persisted UDP ports.

Both ends opt in by LOCAL intent (DialOptions.Datagram on the dialer; the server's
forwarded UDP port registration) — no wire negotiation. NewUDPBridge is no longer
dead code; appnet.DialPacket works.

Tests: accept-side-offers-but-dial-side-doesn't; existing seal→dispatch→open
round-trip + port registry. Build/vet/lint green; full router + appnet suites pass.
Remaining: two-visor UDP-echo live validation.
…stage-4d) (skycoin#3186)

Adds the trigger layer so faithful UDP is actually usable + testable end to end
(previously nothing registered a forwarded_ports.udp or called DialPacket).

Server: `skywire cli skynet port add <port> --udp` registers a UDP datagram
service over skynet (sets ForwardedPort.UDP → RegisterDatagramPort → the accept
loop bridges inbound datagram routes to the local UDP service).

Client: `skywire cli skynet port udp-dial <remote-pk> <remote-port> [--local-port L]`
dials a remote forwarded_ports.udp service and bridges it to 127.0.0.1:L — the
operator's UDP app talks to L and datagrams reach the remote service (replies come
back), loss-tolerant + unordered. Backed by a new visor RPC `DialUDPForward` that
ResolveNetworker(skynet)→PacketNetworker→DialPacketContext and runs a client-side
UDPBridge, tracked in v.udpClientBridges and torn down by `udp-stop` / visor
shutdown. `udp-ls` lists active client forwards.

RPC plumbing: DialUDPForward / StopUDPForward / ListUDPForwards on the API
interface + gateway (UDPForwardIn) + rpc client + mock + proxy stubs (not routed
via hypervisor proxy — use direct --rpc).

Build/vet/lint green; CLI smoke-checked (`port --help`, `port add --udp`,
`udp-dial --help`). Ready for fleet live-validation once boards auto-update.
…rams (skycoin#2607) (skycoin#3187)

* feat(transport): skywire-PK-bound QUIC TLS identity (option A, skycoin#2607 QUIC follow-on)

QUIC mandates TLS 1.3 but skywire authenticates with secp256k1 keys, not a CA.
Following the libp2p-tls pattern, each side presents a self-signed cert with an
ephemeral ed25519 key, and a custom X.509 extension carries the skywire PK plus a
signature (by the skywire SK) over the TLS key. VerifyPeerCertificate checks the
binding and pins the expected remote PK, so a wrong-PK peer is rejected at the TLS
layer before route setup. Self-contained + unit-tested (bind/recover, dialer
pinning, listener learns peer PK, tampered-sig + missing-extension rejected).

Foundation for the QUIC skynet transport (reliable streams first, then RFC 9221
datagrams to make faithful-UDP wire-real).

* test(transport): in-process QUIC + option-A integration proof; disable TLS resumption

Proves the QUIC transport core works end to end before the framework/AR wiring: a
real quic.Dial/quic.Listen connection secured by the skywire-PK-bound TLS identity
mutually authenticates (client pins the server PK, server learns the client PK),
rejects a wrong pinned PK at the handshake, and round-trips bytes on a stream.

Also hardens quicTLSConfig with SessionTicketsDisabled so the full handshake — and
thus the VerifyPeerCertificate identity check — runs on every connection (a resumed
session could otherwise skip it). quic-go promoted to a direct dependency.

* feat(transport): QUIC skynet transport — reliable streams (skycoin#2607 QUIC follow-on)

Adds a QUIC-over-UDP transport type, mirroring sudph/stcpr (AR-resolved) but
riding quic-go with the skywire-PK-bound TLS identity (option A). One QUIC stream
carries one skywire transport; the existing handshake + noise layers run over the
stream exactly as for the other types, so it slots into the transport framework
unchanged.

  - types.QUIC ("quic") + ClientFactory case.
  - quic.go: quicClient (Dial → AR resolve → quic.Dial on an ephemeral UDP socket
    → OpenStreamSync → wrap; Start/listen → UDP socket → quic.Listen → per-conn
    stream-accept → reuse genericClient.initTransport), quicStreamConn (adapts a
    quic.Stream to net.Conn with Local/RemoteAddr from the conn), quicListener
    (net.Listener over *quic.Listener, concurrent stream-accept), AR re-register
    loop mirroring STCPR.
  - addrresolver.BindQUIC (HTTP POST /bind/quic, mirrors BindSTCPR) + APIClient
    interface entry — registers the visor's QUIC UDP port for peers to Resolve.
  - Transport.QUICPort config (opt-in: 0 disables; a fixed port keeps the AR
    registration + firewall rule stable) + initQuicClient module wired into the
    visor init graph.

Datagrams (quic.Config.EnableDatagrams is already on) ride a follow-up
tp.WriteDatagram path that makes faithful-UDP wire-real. The AR SERVER /bind/quic
handler (mirror of /bind/stcpr) is a separate change needed for QUIC to be
resolvable on the live network; until then a visor listens on QUIC but isn't
AR-discoverable. Build/vet/lint green; identity + in-process conn tests pass.

* feat(address-resolver): /bind/quic endpoint so QUIC transports are resolvable (skycoin#2607 QUIC follow-on)

Refactors the STCPR bind handler into a type-parameterized bindForType (STCPR
behavior unchanged — bind() now just calls bindForType(STCPR) and still mirrors to
the v6 AR) and adds POST /bind/quic → bindForType(QUIC), storing the visor's QUIC
UDP address under the "quic" type for peers to Resolve(quic, pk).

Same validation as STCPR (auth, public-IP / declared-PublicIP, hasAddress, v6
declaration). No v6 mirror for QUIC in v1. With this deployed, a visor configured
with quic_port registers its QUIC address and becomes dialable over QUIC by other
QUIC-enabled visors. (Behind-NAT UDP-port discovery / hole-punching is a follow-up;
this targets publicly-reachable nodes.)

Also adds BindQUIC to the AR MockAPIClient. AR api tests pass; build/vet/lint green.

* feat(transport): faithful UDP over QUIC datagrams — wire-real (skycoin#2607 QUIC step 2)

Makes the merged faithful-UDP path actually behave like UDP on the wire (no
head-of-line blocking, genuine loss tolerance) when the underlying transport is
QUIC, by carrying DatagramPackets over QUIC's RFC 9221 datagram channel instead
of the reliable stream. Falls back transparently to the reliable stream on
non-QUIC transports (stcp/sudph/dmsg) or oversized datagrams, so it's always
correct.

  - network: DatagramConn interface { WriteDatagram, ReadDatagram }; *quicStreamConn
    implements it over quic.Conn SendDatagram/ReceiveDatagram. transport.encrypt
    captures the raw conn's datagram channel BEFORE the (stream-only) noise wrapper
    replaces it, exposed via the concrete *transport.Datagram() (not the Transport
    interface, so reliable transports + mocks need no change). The datagram rides
    the raw QUIC conn (its TLS = hop-by-hop encryption; payload already end-to-end
    AEAD-sealed by the DatagramRouteGroup).
  - transport: ManagedTransport.WriteDatagram (native datagram with reliable
    fallback) + datagramReadLoop, started from Serve for datagram-capable
    transports, draining QUIC datagrams into the same readCh the router consumes —
    so the existing DatagramPacket dispatch handles them. Drops on a full readCh
    (faithful-UDP loss tolerance).
  - router: DatagramRouteGroup.WriteTo now uses tp.WriteDatagram.

Tests: real-QUIC datagram round-trip through quicStreamConn; router + transport
suites green. Build/vet/lint clean. With this, two QUIC-enabled visors carry
faithful UDP as true unreliable datagrams end to end.
skycoin#2607) (skycoin#3188)

* feat(dmsg/disc): advertise optional QUIC (UDP) server endpoints (skycoin#2607 dmsg-over-QUIC foundation)

Adds Server.AddressUDP / AddressUDPV6 to the discovery entry so a dmsg server can
advertise QUIC (UDP) endpoints alongside its TCP Address(es), paired with
Entry.Protocol="quic". QUIC-capable clients will dial these for a session with
native QUIC stream multiplexing + an unreliable datagram channel; clients and
servers that don't know QUIC ignore the new fields (omitempty) and use the TCP
Address exactly as today — fully backward-compatible (a QUIC server dual-listens
TCP+UDP). Server.String() surfaces the UDP endpoint for diagnostics.

Additive foundation only — the session-layer rework (QUIC SessionManager backend,
noise-free object exchange over QUIC streams, dial/accept wiring, datagram API)
is the next change.

* feat(dmsg): SessionManager QUIC backend + noise-free object exchange (skycoin#2607 dmsg-over-QUIC)

Additive session-layer groundwork for dmsg-over-QUIC:
- SessionManager gains a quic *quic.Conn backend (exactly one of yamux/smux/quic
  is set); Close handles the QUIC case (CloseWithError).
- writeObject/readObject become noise-optional: when sc.ns == nil (a QUIC
  session) the signed object is written/read straight off the already-TLS-
  encrypted QUIC stream, with no per-object Noise encrypt/decrypt or nonce
  window. The object stays SIGNED, so dmsg-level authentication is unchanged;
  QUIC's PK-bound TLS provides the transport encryption (option A).

Inert until the dial/accept paths construct QUIC sessions (next) — existing
TCP+Noise+yamux/smux sessions are unaffected (the new branches only trigger on
ns==nil / sm.quic!=nil). Builds clean.

* feat(dmsg): Stream carries a native QUIC stream (skycoin#2607 dmsg-over-QUIC)

The dmsg Stream now supports a third underlying mux stream — *quic.Stream —
alongside yamux/smux, collapsed behind a muxStream() helper + muxStreamConn
interface so the stream protocol (writeObject/readObject, deadlines, Close,
nsConn) is transport-agnostic. newInitiatingStream/newRespondingStream open/accept
a QUIC stream when the session is QUIC (sm.quic).

Crucially, the END-TO-END client↔client Noise (s.ns / nsConn) wraps the QUIC
stream exactly as it wraps yamux/smux — so client↔client confidentiality through
the relay is identical across transports (QUIC TLS only secures the client↔server
hop). Additive: the quic branches only trigger for QUIC sessions; existing
yamux/smux behavior is unchanged (dmsg unit tests pass).

* refactor(skyquic): extract PK-bound QUIC TLS identity to a shared package (skycoin#2607)

The skywire-PK-bound QUIC TLS helpers (option A) lived in pkg/transport/network,
but that package imports pkg/dmsg/dmsg (the dmsg transport), so dmsg-over-QUIC
couldn't reuse them without an import cycle. Moved them to a new pkg/skyquic
(depends only on crypto/* + pkg/cipher), exported as NewCertificate / TLSConfig /
VerifyCert. network/quic_identity.go is now thin wrappers so the merged skynet
QUIC transport + its tests are unchanged; the identity unit tests moved to
pkg/skyquic. Both packages build; all QUIC + identity tests pass.

* feat(dmsg): QUIC session dial + server-session accept (skycoin#2607 dmsg-over-QUIC)

- session_common: initQUIC sets up a session over a *quic.Conn with no Noise
  handshake (QUIC TLS authenticated the peer PK + encrypts the hop); quicAddrConn
  satisfies the addr-only netConn accessors.
- client: dialSession picks QUIC when the server advertises Protocol "quic" +
  AddressUDP — dialSessionQUIC does quic.Dial with the PK-bound TLS (pkg/skyquic,
  pins the server PK), then makeClientSessionQUIC (no Noise). The shared
  newest-session-wins storing logic is reused for both transports.
- server: makeServerSessionQUIC + a QUIC AcceptStream branch in ServerSession.Serve
  (mirrors yamux/smux); serveStream's read-deadline assertion broadened to cover
  QUIC streams (which have SetReadDeadline but aren't net.Conns).

Additive — QUIC paths only trigger for QUIC sessions; existing TCP+Noise+yamux/smux
behavior unchanged (dmsg unit tests pass). Remaining: the server QUIC listener
(ServeQUIC/handleQUICConn) + AddressUDP advertisement + server-binary wiring.

* feat(dmsg): server QUIC listener + advertisement (skycoin#2607 dmsg-over-QUIC)

- Server.ServeQUIC(udpConn, advertisedUDPAddr): QUIC listener over the given UDP
  socket using the PK-bound TLS (pkg/skyquic, option A), run alongside the TCP
  Serve. Each accepted conn → handleQUICConn, which authenticates the peer PK from
  its QUIC TLS certificate (quicPeerPK → skyquic.VerifyCert), builds a QUIC server
  session (no Noise), and serves it — mirroring handleSession's lifecycle
  (setSession / Serve / peer cleanup / delSession).
- EntityCommon.advertisedUDPAddr (set by ServeQUIC) → updateServerEntryOnEndpoint
  publishes Server.AddressUDP + Protocol "quic" so QUIC-capable clients dial QUIC;
  TCP-only servers/clients are unchanged (omitempty + dual-advertise).

dmsg-over-QUIC is now complete at the library level: discovery, the shared
skyquic identity, the QUIC SessionManager backend, native-QUIC-stream dmsg
streams (end-to-end client↔client Noise preserved), client dial, and server
accept. Full build green; pkg/dmsg/dmsg + disc tests pass (the only failure in
./pkg/dmsg/... is the pre-existing Redis-needs-localhost store test). Remaining:
wire the dmsg-server binary to call ServeQUIC + optional session datagram API.

* feat(dmsg): dual-listen TCP+QUIC in the server binary + client TCP fallback (skycoin#2607)

- dmsgserver.ListenAndServe now also binds a UDP listener (same port number as
  TCP) and runs Server.ServeQUIC alongside the TCP Serve — best-effort + additive
  (a QUIC bind failure leaves TCP serving). Deployed servers that update to this
  code dual-listen and advertise their QUIC endpoint automatically.
- Client: when a QUIC dial fails (e.g. UDP blocked by a firewall) the client falls
  back to the server's TCP endpoint, which a QUIC-advertising server also listens
  on. Robust for UDP-hostile networks; behaves exactly like a normal TCP dial on
  fallback.

dmsg-over-QUIC is now COMPLETE end to end: a QUIC-capable visor dials a
QUIC-advertising dmsg server over native QUIC streams (no Noise handshake; PK-bound
QUIC TLS + preserved end-to-end client↔client Noise), with full backward
compatibility (omitempty discovery fields, dual-listen, TCP fallback). Build/lint
green; dmsg unit tests pass.

Follow-up: expose a session-level datagram API (QUIC SendDatagram/ReceiveDatagram)
+ a two-server live test.

* feat(dmsg): session-level datagram channel over QUIC (skycoin#2607 dmsg-over-QUIC)

Exposes the payoff of dmsg-over-QUIC: SessionCommon.WriteDatagram / ReadDatagram /
SupportsDatagrams give dmsg a genuine unreliable datagram channel (QUIC RFC 9221
SendDatagram/ReceiveDatagram) — UDP-over-dmsg — riding the same PK-bound-TLS-
encrypted QUIC connection as the session's streams. Non-QUIC (TCP+yamux/smux)
sessions return ErrDatagramsUnsupported (they carry reliable streams only),
resolving the coupling: dmsg can faithfully carry UDP only when it runs over a
datagram-capable transport, which is now QUIC.

Test covers the non-QUIC contract; the round-trip is exercised by the two-server
live test. This completes dmsg-over-QUIC end to end.

* fix(skyquic): actually commit the extracted package + quic_identity wrappers (skycoin#2607)

The earlier skyquic-extraction commit silently dropped its payload: the
'git add ... quic_identity_test.go' errored on the already-removed test, which
aborted the whole git add, so pkg/skyquic/ and the quic_identity.go thin wrappers
were never staged. HEAD kept the old full quic_identity.go and had no skyquic
package — but the dmsg-over-QUIC commits import pkg/skyquic, so the pushed branch
didn't compile and CI failed at the lint/build step. (Local builds passed only
because the files existed untracked in the working tree.)

This commits pkg/skyquic (identity.go + identity_test.go) and the quic_identity.go
wrappers. Whole-repo build + CI-config golangci-lint on the dmsg/skyquic/transport
packages are clean.
…e URLs (skycoin#3189)

* feat(dmsg): dmsg-over-WebSocket transport

Add WebSocket as a third dmsg session transport alongside TCP and QUIC.
A WebSocket is a bidirectional, ordered, reliable byte pipe over HTTP(S),
so coder/websocket's NetConn adapts it to a net.Conn and the EXISTING
Noise+yamux session stack runs over it unchanged — no new session
semantics, mux, or crypto. The per-hop Noise handshake and end-to-end
per-stream noise are both preserved; WS only changes how the bytes are
carried.

Why: a browser tab (JS or Go js/wasm) cannot open a raw TCP or UDP
socket, so neither the legacy TCP transport nor dmsg-over-QUIC can reach
the mesh from a browser. WebSocket is the one transport the sandbox
allows, making a future WASM dmsg client — a real PK-authenticated leaf
node in a web page — possible. It is also the universal fallback for
restrictive networks that only permit HTTP(S)/443 and for CDN fronting.
coder/websocket is used because it also compiles to js/wasm, so the same
dial path serves the browser client.

- disc.Server.AddressWS advertises a ws:// or wss:// URL; orthogonal to
  Protocol (WS reuses yamux), so a server can advertise TCP + QUIC + WS
  at once, each dialed by the clients that can use it.
- Server.ServeWS serves WS on a plaintext listener (TLS terminated by a
  front proxy); the WS handler hands the NetConn to the shared
  handleSession path.
- Client dials WS when Config.PreferWS is set and the server advertises
  AddressWS, with TCP fallback; forced on in the js/wasm build later.
- dmsg-server service gains optional ws_address + public_address_ws
  config (off by default, additive).
- ws_test.go: full end-to-end test — a server listening ONLY over WS,
  two PreferWS clients, a stream bridged between them.

* fix(tpviz,config): stop fetching clearnet http; centralize service URLs on services-config.json

The tp-viz UI was auto-refreshing transport/uptime data over hardcoded
clearnet http (tpd.skywire.skycoin.com/all-transports,
ut.skywire.skycoin.com/uptimes) — both now 404 (TPD http frontend
deprecated, uptime tracker decommissioned), spamming "Auto-refresh
failed" warnings.

tp-viz:
- When embedded in a visor, source all-transports from the visor's
  dmsg/CXO-backed transport-discovery feed (new VisorAPI.AllTransports →
  Visor.FetchAllTransportsCXO) — never clearnet. On a CXO cache miss,
  surface 503 and let the UI retry rather than hitting a dead endpoint.
- Drop the standalone uptime tracker entirely (decommissioned): UTURL
  defaults empty, handleUptimes serves an empty map, refreshCache and
  the --ut-url CLI default no longer point at a dead endpoint.
- index.html: drop the hardcoded https://tpd.skywire.skycoin.com
  file:// fallback; always use the serving origin's relative /api.

Hardcoded-URL audit — services-config.json is the single source of
truth for deployment URLs; remove dead duplicates from code:
- Delete pkg/skyenv/values.go entirely. Every URL constant (prod+test)
  was already dead (consumers use deployment.Prod / gen.go services.* /
  dmsg.DiscAddr), and the PK lists (route_setup_nodes, transport_setup,
  survey_whitelist, reward_system) + stun_servers + dns_server all
  duplicate services-config.json. The one live ref (visor ip → STUN)
  is repointed to deployment.Prod.StunServers.
- rewards: drop the hardcoded tpd.skywire.skycoin.com fallback.
- network-monitor: --sd-url defaults to deployment.Prod.ServiceDiscovery.

* fix(cli): visor ip reports the visor's already-known public IP, not a fresh STUN round

The visor resolves its public IP + NAT type via STUN once at startup (for
SUDPH/STCPR + the address resolver) and holds it in its Overview. `cli
visor ip` was running its OWN STUN round — redundant work that could even
disagree with the visor's own view. Now it just asks the running visor for
what it already knows.

- Add NATType to Overview (the visor's STUN-classified NAT type string),
  alongside the existing PublicIP.
- `visor ip` calls rpcClient.Overview() and prints PublicIP + NATType.
- Standalone fallback (no visor reachable) still runs a local STUN round
  with the embedded default servers — the only option without a visor.
…in#3190)

* feat(dmsg): compile the dmsg client to js/wasm + a browser WASM client

Make the dmsg client buildable for the browser (GOOS=js GOARCH=wasm) and add
a WASM entrypoint that exposes a dmsg API to JS. This is the foundation for a
browser-native dmsg client — chat, WebRTC signaling, and (the real target) a
zero-install hypervisor UI that talks to the fleet over dmsg.

Build-tag retargeting (js/!js -> tinygo/!tinygo):
- The js stubs in pkg/logging, pkg/dmsg/disc and deployment existed because
  TinyGo lacks the reflect/encoding-json runtime helpers — NOT because the js
  platform can't run the real code. Standard Go js/wasm has the full stdlib
  and runs logrus/encoding-json/gob fine. Retargeting the constraints to
  tinygo/!tinygo gives standard-Go-js the full implementations (so the dmsg
  client, which uses logrus.FieldLogger + disc.Entry.Sign + the HTTP discovery
  client, compiles) while the TinyGo install-page build keeps its minimal
  stubs unchanged. Standard Go is also required regardless because the
  hypervisor management channel is gob-RPC (reflection-heavy).
- deployment/cmd/gen emits //go:build tinygo for the generated data file.

cmd/dmsg-wasm:
- main.go: standard-Go js/wasm client. Forces Config.PreferWS (the browser
  sandbox has no raw TCP/UDP — WebSocket is the only transport). Inbound still
  works: once connected to a dmsg server over WSS, peers dial this client BY PK
  and the server bridges the streams back down the same connection, so a tab is
  a first-class inbound-reachable peer. Exposes globalThis.skywireDmsg with
  connect / dial / listen and per-stream send / onMessage / close.
- index.html: dev harness (connect, dial, exchange messages).
- Makefile: `dmsg-wasm` (dev build) and `dmsg-wasm-inline` (single
  self-contained .html with wasm_exec.js + base64-inlined wasm — runs from
  file://, no static host, no fetch).

Native + existing wasm builds unaffected; native logging/disc/deployment/dmsg
tests pass. (Standard-Go wasm is ~19MB / ~5MB gzipped — fine for a hypervisor
app; size optimization is a follow-up.)

* feat(dmsg): seeded WS bootstrap for the browser client (dmsg-only discovery)

A browser can't reach a dmsg-only discovery until it has a server (the HTTP
discovery frontend is gone). StartDmsgSeeded solves the chicken-and-egg:

1. Seed one server's {PK, AddressWS} into a direct.Client so the dmsg client
   connects straight to it over WebSocket (PreferWS), no HTTP discovery needed.
2. Once the session is live, upgrade discovery to a registering fallback over
   dmsg (dmsghttp) so the client registers its own entry in the real discovery
   (→ inbound-reachable by PK) and resolves arbitrary peers.

The discovery's own client entry is preloaded too (delegated to the seed
servers) — without it, the first reach to the discovery over dmsg recurses
resolving the discovery's own location until the stack overflows.

Validated against the live WS-enabled prod02 server (0371ab4b) with a WS-ONLY
seed (no TCP fallback): connects over WebSocket and "Initial post entry
succeeded" — registered in the real dmsg-discovery over dmsg. Wired into
cmd/dmsg-wasm connect(sk, seedPK, seedWsURL, discDmsgAddr); harness pre-filled
with the live server.

* feat(dmsg-wasm): HTTP-over-dmsg fetch primitive (browser hypervisor-UI transport)

Add skywireDmsg.fetch(pkHost, method, path, body) — an HTTP request over dmsg to
dmsg://<pk>:80<path>, built on dmsghttp.MakeHTTPTransport over the seeded
session. This is the transport a browser hypervisor UI uses to reach any
visor/hypervisor BY PUBLIC KEY (no clearnet, no exposed port). Validated against
the live network: GET /health over dmsg returned 200 + real build_info. Harness
gains a fetch demo.

* feat(hypervisor): optionally serve the UI/API over dmsg (DmsgUIPort)

Serve the hypervisor's HTTP handler (hv.HTTPHandler() — full web UI + REST API)
over a dmsg listener when HypervisorConfig.DmsgUIPort is set, mirroring how the
tpviz UI is already served over dmsg. This lets a browser dmsg client (the WASM
hypervisor UI) reach the fleet BY PUBLIC KEY with no exposed HTTP port,
end-to-end over dmsg. Opt-in (0 = off) since it exposes fleet control over dmsg;
the handler's own login/CSRF auth applies and the dmsg Noise layer authenticates
the caller PK. Completes the server side of the browser-hypervisor chain:
browser wasm client → HTTP-over-dmsg → this listener → hv API.

* feat(dmsg-wasm): Service-Worker hypervisor UI over dmsg (the browser port)

Make the UNMODIFIED hypervisor Angular UI load in a browser entirely over dmsg,
reached BY PUBLIC KEY. A Service Worker (sw.js) runs the WASM dmsg client and
transparently proxies every in-scope navigation + fetch over dmsg to the
hypervisor's DmsgUIPort listener via skywireDmsg.fetch — index.html, the JS/CSS
bundles, /api/*, /assets/* all arrive over dmsg; the page origin only serves the
four local assets. hv.html is the bootstrap (registers the SW, then navigates to
"/").

jsFetch is now binary-safe (Uint8Array body) + forwards request/response headers
(cookies/CSRF/content-type) so arbitrary assets + authenticated requests proxy
correctly. Makefile: `dmsg-wasm-hv` builds the bundle.

PROVEN in a real headless browser against live infrastructure: wasm connects to
the live prod02 server over ws://, registers in dmsg-discovery, fetches the
hypervisor /api/ping over dmsg (200 "PONG!"), and the SW loads the full
hypervisor Angular index (app-root present) over dmsg. End-to-end: browser →
wasm dmsg client → ws:// → dmsg → hypervisor UI+API by PK.

Note: a production browser (https/file:// page) needs the seed server on wss://
(secure context can't open ws:// to a remote); the headless test used
--disable-web-security to allow plain ws://.

* fix(tpviz): source deployment discovery over dmsg, gate dead clearnet fetches

The standalone tp-viz (reward UI) auto-refreshed TPD/SD/DMSG discovery over the
clearnet HTTP frontends, which are gone on the dmsg-only deployment → continuous
404 spam ("[tp_viz] Auto-refresh failed ... http://dmsgd.skywire.skycoin.com/...").

- tp-viz gains SetDmsgHTTPClient + dmsg:// discovery bases (deployment.Prod.*Dmsg).
  When a dmsg client is set, TPD/SD/DMSG discovery is fetched over dmsg.
- When neither a dmsg client nor a visor API is available (dmsg-only, no source),
  the clearnet deployment fetches are GATED (skipped) instead of hammering dead
  URLs. AllowClearnetDisc forces the legacy behavior.
- The reward UI starts an embedded dmsg client (StartDmsgEmbedded — seeded from
  the deployment's dmsg servers, discovery over dmsg, no clearnet) and hands
  tp-viz an HTTP-over-dmsg client. Validated: connects over dmsg, registers, logs
  "deployment discovery now sourced over dmsg"; the [tp_viz] 404 spam is gone.
- StartDmsgSeeded gained a preferWS flag (wasm=WS, native=TCP); StartDmsgEmbedded
  is the clearnet-free native bootstrap from the embedded server set.

* fix(dmsg,rewards): drop dead clearnet-http discovery flags + bootstrap

The deployment is dmsg-only; the clearnet dmsg-discovery HTTP frontend is gone
(404), so flags/paths that connect to discovery over plain http no longer work.

- dmsg CLI (dmsg curl/dial/web/probe/http via dmsgclient.InitFlags): remove the
  -Z/--http ("use regular http to connect to DMSG Discovery") flag + its branch
  in InitDmsgWithFlags (it Fatal'd on the 404), and the -U/--disc-url clearnet
  URL flag. Discovery is reached over dmsg via -A/--disc-addr (dmsg://, the
  default); -B/--direct uses the embedded server set with no discovery. Drop the
  now-dead UseHTTP var + stale doc.
- reward-post dmsg server (rewards ui / serveStandalone): bootstrap via
  dmsgclient.StartDmsgEmbedded (embedded servers, discovery over dmsg) instead of
  disc.NewHTTP(clearnet) which couldn't connect.

Validated: the reward UI now starts with ZERO clearnet 404s (was continuous
spam); both its dmsg clients register + connect over dmsg.

* fix(tpviz): fetchAny clearnet branch must call fetchURL, not recurse

A sed during the dmsg-fetch refactor rewrote fetchAny's clearnet fallback to
call s.fetchAny(url) instead of fetchURL(url) — infinite recursion on any http://
URL (and left fetchURL unused). golangci-lint caught it via the unused-func +
unused-var (errGatedClearnet, also removed). Restore the fetchURL call.

* feat(wasmhv): HTTP-over-dmsg override for the single-file (no-SW) browser hypervisor

The Service Worker can't run from file://, so the self-contained single-file UI
uses this fetch/XHR override instead: installed before the inlined Angular
scripts, it routes every same-origin request (the app's /api calls) over dmsg
via skywireDmsg.fetch. Also handles WebCrypto AES-GCM/PBKDF2 password-decryption
of an embedded secret key (key never touches disk).

Validated separately: an inlined Angular dist boots fully from file:// (lazy
webpack chunks self-register, fonts/CSS inlined), leaving only /api — which this
override supplies over dmsg. The Go inliner + generator command that bakes the
dist + this override + the wasm client into one hypervisor.html is the next step.

* feat(wasmhv): standalone wasm hypervisor core — visors dial the browser tab

A browser tab can now BE a hypervisor (not just a remote viewer of one): it
accepts visor dials on the hypervisor dmsg port (46), runs the gob RPC client
against each dialed-in visor, and serves the hypervisor /api surface — so visors
that list the tab's PK "just show up", with no binary hypervisor behind it.

- pkg/wasmhv: a wasm-compilable hypervisor core that does NOT import pkg/visor
  (which doesn't compile to wasm). It declares MINIMAL MIRROR TYPES of the visor
  API responses (About/Overview/HealthInfo/Summary) with matching gob field
  names + json tags; gob decodes the real reply and ignores the rest. Core.Serve
  accepts visor dials → rpc.NewClient per visor; Core.ServeHTTP routes the read
  endpoints the node dashboard needs (/about, /ping, /visors, /visors-summary,
  /visors/{pk}[/summary|/health], no-auth user/csrf stubs).
  (Overview omits Apps/Transports from the gob struct — a mismatched element
  type makes gob fail the whole decode — and re-adds empty arrays via MarshalJSON.)
- cmd/dmsg-wasm: serveHypervisor() starts the core; hvApi(method,path,body)
  serves a request from it (the standalone-mode equivalent of fetch() to a
  remote hypervisor).

PROVEN end-to-end in a real headless browser against the live network: the tab
connects over ws://, serves on dmsg :46, the local visor dials in (cli visor hv
add <tabPK>), and /api/visors returns the visor's REAL Overview (version, IPs,
NAT type, route count) via in-wasm gob-RPC.
…ck + autoconnect wedge (skycoin#3191)

* fix(autoconnect): bounded SD fetch + prefer dmsg URL — no infinite retry, no clearnet

A v1.3.77 visor stopped creating transports via public autoconnect after running
a while. pprof showed its autoconnect goroutine wedged at autoconnect.go:446 —
inside fetchPubAddresses' retrier on the service-discovery fetch.

Two issues:

1. Infinite retry wedge. NewDefaultRetrier uses DefaultTries=0 (retry FOREVER).
   When the SD fetch fails repeatedly — the SD unreachable over its transport
   (the default deployment is dmsg-only, so this is the dmsg SD; a stale/broken
   dmsg path to it loops indefinitely) — retrier.Do never returns and the whole
   autoconnect Run loop wedges on a single tick: no public visors fetched, no
   transports created, and the CXO snapshot (re-triggered at the top of
   fetchPubAddresses each tick) never gets another chance to populate. Fix: a
   bounded retrier (3 tries) + 20s per-fetch timeout. The Run loop's ticker
   already provides the retry cadence, so a failing SD degrades to "no public
   visors this tick" and the loop self-heals next cycle.

2. HTTP-first SD URL selection. startPublicAutoconnectInternal preferred the
   clearnet HTTP SD URL over the dmsg one. The autoconnect's discovery client is
   the dmsg-HTTP transport, so a clearnet URL can't work here anyway (it gets
   dialed as a dmsg PK → "invalid host address"). The default config is dmsg-only
   (no clearnet URLs at all — verified), so prefer the dmsg URL and never a
   clearnet HTTP SD: no plain-HTTP service-discovery fallback.

* refactor(visor): rip out plain-HTTP to deployment services — dmsg-only

Plain HTTP to deployment services (AR/TPD/RF/SD/dmsg-disc/...) is no longer
supported. getHTTPClient is the central transport builder for every
deployment-service client; it now returns an ERROR for any non-dmsg:// URL
instead of silently building a clearnet http.Client. A clearnet service URL is
a misconfiguration and fails loud.

- getHTTPClient: drop the plain-http branch; non-dmsg URL → error.
- initAddressResolver: drop the "DMSG-HTTP failed, using plain HTTP" fallback +
  the v6-forced clearnet client (only meaningful for a plain-HTTP AR); take the
  dmsg URL (AddressResolverDmsg, or AddressResolver which holds the dmsg:// URL
  in the dmsg-only default) and route it over dmsg.

Validated: local visor boots clean on this change, connects to the address
resolver over dmsg and binds STCPR/SUDPH — no clearnet, no "not a dmsg" errors.

* refactor(visor): SD service fetch + service list are dmsg-only

Continue the plain-HTTP-to-deployment-services rip-out in api_services:

- serviceFetchOrder: only dmsg:// endpoints; clearnet URLs ignored. Also fixes
  a latent bug — the dmsg-only default config stores the dmsg URL in the 'http'
  field (service_discovery = dmsg://...), which the old order mis-classified as
  a plain-HTTP hop and would GET over clearnet. Now any dmsg:// URL (either
  field) is routed over dmsg. Removed fetchServiceDataHTTP + serviceHop.dmsg.
- servicesFromHTTP → servicesFromDmsg: the VPN/proxy/visor service lists (the
  CXO-snapshot fallback) used a plain clearnet http.Client; now they resolve the
  dmsg SD URL and fetch over the dmsg-HTTP client (getHTTPClient).

* refactor(visor): health probes + hypervisor UI proxies are dmsg-only

Finish the plain-HTTP rip-out in the visor's auxiliary/UI paths:

- ServiceHealth: probe every deployment service over dmsg only (drop the
  clearnet http.Client fallback). The dmsg URL is taken from whichever config
  field holds the dmsg:// one (the dmsg-only default stores it in the 'http'
  field); services with no dmsg URL report N/A.
- hypervisor TPD-metrics proxy (getNetworkTransports): drop the 'Step 3 plain
  HTTP' fallback + the clearnet deployment.Prod.TransportDiscovery default;
  dmsg-only.
- hypervisor reward-system proxy (proxyRewardSystem): drop the plain-HTTP
  fallback; dmsg-only.

Remaining (separate, dmsg-package layer): the dmsg client's own discovery
upgrade (dmsgfirst) still carries an HTTP fallback — inert on a dmsg-only
deployment (the disc URL is dmsg://), but it's the dmsg-bootstrap path so it
warrants its own careful change.

* test(visor): update TestServiceFetchOrder for dmsg-only fetch

serviceFetchOrder is now dmsg-only (clearnet hops dropped, serviceHop.dmsg field
removed). Update the test: only dmsg:// URLs are returned, the dmsg URL is taken
from whichever field holds it (the dmsg-only default stores it in the http
field), and clearnet-only configs return nil.
…kycoin#3193)

Adds WebTransport (HTTP/3 over QUIC) as a fourth dmsg transport alongside
TCP, QUIC and WebSocket. WebTransport is the one browser-reachable transport
that needs NO CA-issued certificate: the browser's WebTransport constructor
accepts a self-signed cert via serverCertificateHashes (a pinned SHA-256), so
a dmsg server can present a short-lived self-signed ECDSA cert, publish its
hash in discovery, and a browser connects by bare IP — no Caddy, no domain,
no Let's Encrypt.

Like WS, a WT session carries the EXISTING Noise+yamux session over a single
bidirectional stream, so the per-hop Noise handshake (which authenticates the
client PK, since a browser can't present a client cert) and the session layer
are unchanged. WT's native stream multiplexing is intentionally unused — yamux
multiplexes inside the one WT stream, keeping a single session implementation.

- pkg/skyquic/webtransport.go: NewWebTransportCertificate (ECDSA P-256,
  13-day validity, no PK-binding extension) + sha256 hash; WebTransportTLSConfig.
- pkg/dmsg/dmsg/wt.go: Server.ServeWebTransport (caller owns cert for
  deterministic rotation), handleWTSession, wtStreamConn net.Conn adapter, and
  Client.dialSessionWT (native path; pins CertHashWT, disables resumption so a
  resumed session can't bypass the pin).
- disc.Server.AddressWT + CertHashWT advertised from EntityCommon; PreferWT
  client config + dial-switch wiring with TCP fallback.
- dmsgserver config wt_address/public_address_wt; dmsgsrv serves WT best-effort.
- wt_test.go: full bridged round-trip over WT-only server + cert-hash-mismatch
  rejection test.

Vendors github.com/quic-go/webtransport-go v0.11.0 (+ dunglas/httpsfv).
…hypervisor (skycoin#3194)

Extends the wasm hypervisor core (skycoin#3190) from a read-only dashboard to a
controllable one: the apps (list / start / stop / autostart) and transports
(list / types / add / remove) endpoints the hypervisor UI drives daily.

As with the read path, the core does NOT import pkg/visor (which doesn't
compile to js/wasm) — it declares MINIMAL MIRROR TYPES of the visor RPC
request/reply structs (AppState, TransportSummary + its custom-gob LogEntry,
and the StartApp/SetAutoStart/Transports/AddTransport argument structs). gob
matches struct fields by name and skips the rest, so a partial mirror decodes
the fields the UI reads and encodes the fields a control call needs.

- control.go: mirror types + control calls (apps/app/startApp/stopApp/
  setAutoStart/transports/transportTypes/addTransport/removeTransport).
  TransportLogEntry reproduces transport.LogEntry's custom Gob/JSON codec so
  per-transport bandwidth shows.
- router.go: method-aware /visors/<pk> routing — GET apps/transports,
  PUT apps/<app> (status + autostart), POST transports, DELETE transports/<id>.
- override.js: standalone mode (CFG.standalone) — route /api to the in-wasm
  core (hvApi) and call serveHypervisor() on connect, instead of proxying to a
  remote hypervisor over dmsg. One shared dispatch() picks the path.
- gob_mirror_test.go (host-only !js): gob round-trips the REAL visor types
  through the mirror types in BOTH directions, so a field rename on either
  side fails a test instead of silently zeroing a value in the browser.
… dmsg-only (skycoin#3195)

The deployment discovery services (dmsg-disc / TPD / SD / AR) are no longer
served over clear HTTP, so the dmsgfirst "try dmsg, retry on plain HTTP" disc
client's fallback leg only added a doomed roundtrip + a WARN per refresh when a
dmsg dial transiently failed (e.g. dmsg-202 session-overlap). Worse, it could
mask a transient dmsg blip with a dead HTTP endpoint.

upgradeDmsgDiscToDmsgfirst → upgradeDmsgDiscToDmsgOnly: each per-deployment disc
client now registers + resolves STRICTLY over dmsg (dmsg-HTTP through the
direct-client dmsgDC), mirroring dmsgsrv's newDmsgOnly. A transient dmsg error
just lets the caller retry over dmsg on its next periodic refresh /
re-registration, which self-heals once sessions realign. The pk-absent branch
(no discovery_dmsg configured) still uses the explicit plain-HTTP Discovery URL
— that is the operator's "no dmsg discovery" config choice, not the runtime
fallback being removed.

Deletes pkg/dmsg/disc/dmsgfirst (the visor was its only consumer) and the now
-stale references in pkg/router/map.go and init_dmsg.go comments.

Validated on a live visor: clean boot, 9 dmsg servers, TPD transports list +
AR reward resolve over dmsg with no HTTP fallback path.
…ride.js (skycoin#3196)

Design doc (docs/design/unified-wasm-hypervisor-ui.md) for collapsing the two
hypervisor front-ends (visor-served Angular UI + the WASM hypervisor) into ONE
HTML+wasm artifact that detects its context and picks a role: served (dormant,
ride the serving visor's session — no key), viewer (dial a remote hypervisor
PK), or standalone (this tab IS the hypervisor, visors dial in). Captures the
two server→serverless enable-flows, the AddHypervisor RPC affordances Mode 2
needs, and the precise security boundary: never serve the KEY-ENTRY variant
from a domain; the served variant is safe anywhere because it takes no key.

Lands Phase 1 — the context-detection primitive in override.js: it now only
installs the fetch/XHR shims (and boots the wasm) when a mode is configured
(CFG.pk or CFG.standalone, and not CFG.served). With neither set it returns
early and native fetch/XHR pass through to the serving backend — so the same
bundle can be served by a visor (behaves as today's UI) or opened from file://
(serverless), which is the prerequisite for serving the wasm UI in place of the
Angular build. No current consumer (the single-file generator is Phase 2).
0pcom added 16 commits June 20, 2026 20:07
…nyGo dmsg port plan (skycoin#3197)

The lightweight TinyGo logging stub was pinned to GOOS=js by its filename
(logging_js.go — Go's implicit _js.go GOOS rule overrides the //go:build tinygo
tag), so under wasip1/bare-metal TinyGo pkg/logging had no files at all
("build constraints exclude all Go files"). Renamed to logging_tinygo.go so the
//go:build tinygo tag covers js, wasip1, and bare-metal alike.

Production (!tinygo) and standard-Go js/wasm builds are unaffected — they never
used the stub (verified: go build ./..., GOOS=js GOARCH=wasm build, and the
logging tests all stay green).

Adds docs/design/tinygo-dmsg-client.md with the empirically-measured blocker map
(TinyGo 0.41: gob/json/logrus all compile; net/http works on wasip1 but is broken
on the js target; quic-go won't compile) and the phased plan. The only remaining
blocker for a TinyGo IoT (wasip1) dmsg client is quic-go woven into the session
core, to be extracted behind an interface into !tinygo files next.
…HitTracker flake (skycoin#3192)

PortHit ordering (snapshot + eviction) used LastSeen, a wall-clock time. On
Windows' ~15ms coarse timer, several hits recorded in quick succession share an
identical LastSeen, so the most-recent-first sort was non-deterministic and
TestPortHitTracker flaked on the windows CI runner (snapshot[0] not the
last-recorded hit).

Add a monotonic seq counter, bumped on every record, and order by it instead of
LastSeen (eviction picks the lowest seq, snapshot sorts seq-descending). seq is
unexported so the JSON shape is unchanged. Test passes 50x.
…UIC stream bugs (skycoin#3198)

* feat(dmsg)+tinygo: extract QUIC behind an interface; fix two latent QUIC stream bugs

TinyGo Phase 2: decouple quic-go from the dmsg session core so a TinyGo (IoT)
dmsg client compiles. quic-go does not build under TinyGo (needs
crypto/tls.QUICEncryptionLevel), and it was woven into six session-core files as
*quic.Conn / *quic.Stream struct fields. Now the core refers to QUIC only via
the quicConn / quicStream interfaces (quic_iface.go); the concrete adapters,
session constructors, client dial and server listener live in quic_native.go
(//go:build !tinygo), with TinyGo stubs in quic_stub.go that fall back to TCP/WS.
wt.go (WebTransport, also quic-go) gets //go:build !tinygo. Result: pkg/dmsg/dmsg
no longer pulls quic-go/webtransport-go under the tinygo tag.

Writing the first dmsg-over-QUIC round-trip test (quic_test.go — a QUIC-only
server, two clients, a bridged A→B stream) surfaced TWO pre-existing bugs in the
dmsg-over-QUIC stream path that no test had ever exercised:

  - server forwardRequest() opened the destination stream with a smux/else-yamux
    branch — for a QUIC destination it dereferenced a nil *yamux.Session and
    panicked serveStream. Added the QUIC case (OpenStreamSync).
  - the client accept loop never treated a QUIC AcceptStream error as terminal
    (no quic case in the session-closed switch), so it span forever on a closed
    QUIC session and hung ClientSession.Close(). quic-go's AcceptStream only
    errors on a done connection, so any error there is now terminal.

Supporting TinyGo proofing (logrus/json compile under TinyGo 0.41, unlike the
skycoin#2836-era assumption): pkg/logging now builds its real logrus implementation on
all targets (dropped the //go:build !tinygo tags + the no-op logging_tinygo.go
stub, which couldn't satisfy logrus.FieldLogger for the full client anyway); the
VictoriaMetrics-backed Metrics impl is tagged !tinygo (server-only; the client
uses the no-op Empty).

Regular go build ./... + the full pkg/dmsg/dmsg suite (incl. the new QUIC
round-trip) stay green; the QUIC extraction is verified quic-free under
`go list -tags tinygo`. The remaining TinyGo blocker is pkg/netutil
(interface-enumeration), tracked as Phase 3 in docs/design/tinygo-dmsg-client.md.

* docs(tinygo): mark QUIC extraction (Phase 2) done + note the two QUIC bug fixes; netutil is the Phase 3 blocker
skycoin#3199)

The dmsg client now builds under TinyGo for the wasip1 (IoT) target:
`make tinygo-dmsg` (tinygo build -target wasip1 -no-debug -opt=z) produces a
~2.2 MB wasm binary. Build-check main in cmd/dmsg-tinygo-probe.

Clears the remaining peripheral blockers, each with the same
!tinygo-split-plus-stub (or de-tag-the-obsolete-!tinygo) pattern established in
Phase 2:

  - pkg/netutil: net.go used net.Interface.Addrs() (absent in TinyGo's net) +
    a GOOS-split DefaultNetworkInterface with no wasip1 variant. Pure helpers
    (IsPublicIP/ExtractPort/IsVirtualInterface) stay in net.go; the
    interface-enumeration + ipinfo HTTP probe move to net_native.go (!tinygo)
    with stubs in net_tinygo.go.
  - pkg/dmsg/disc: Entry.Sign / VerifySignature were !tinygo on the stale
    "encoding/json can't run under TinyGo" assumption (false since 0.41).
    De-tagged (entry_native.go → entry_sign.go) with a stdlib encoding/json
    codec shim (interface_tinygo.go) standing in for jsoniter under TinyGo.
  - pkg/dmsg/dmsg: *net.TCPConn.SetNoDelay (absent in TinyGo) wrapped in a
    build-tagged setTCPNoDelay helper (no-op on TinyGo).
  - deployment: EnvServices moved from config_native.go (!tinygo) to the
    untagged config.go so pkg/dmsg/dmsg's InitConfig can decode the embedded
    config on TinyGo too.

Adds the `tinygo-dmsg` Makefile target and updates
docs/design/tinygo-dmsg-client.md (Phase 3 done). Normal go build ./... +
pkg/dmsg/dmsg, disc, netutil, logging, deployment test suites stay green.
…assembler) (skycoin#3200)

GenerateStandalone assembles a self-contained, serverless hypervisor.html from
the built Angular UI + the WASM dmsg client + override.js — a single file with
NO external references that boots the in-browser dmsg client and runs the
hypervisor UI from file://. This is the keystone of the unified-UI plan
(docs/design/unified-wasm-hypervisor-ui.md): "save the hypervisor UI to an HTML
file and open it serverless."

What it does:
  - inlines every Angular chunk <script src> (preserving type="module" so the
    deferred/ordered execution is kept) and every <link rel=stylesheet> as a
    <style> (deduped — Angular emits the sheet twice);
  - embeds the wasm gzip+base64 and decompresses it in-browser via
    DecompressionStream, then instantiates + runs it;
  - injects override.js + window.__SKYWIRE_HV__ (dmsg seed/disc + identity) as
    classic <head> scripts BEFORE the Angular module scripts, so the XHR/fetch
    shim is installed before HttpClient is constructed;
  - bakes the secret key in password-encrypted (PBKDF2-SHA256/200000 + AES-GCM,
    base64[salt|iv|ct] — the exact format override.js resolveSK decrypts); the
    plaintext key never touches the file. Without a password it's ephemeral or
    (testing only) a bare sk.

override.js is embedded (embed.go) so consumers only supply the UI FS + wasm +
wasm_exec.js. Tests cover structure (no external refs, dedup, ordering,
</script> neutralization), viewer vs standalone CFG, and an encsk encrypt→decrypt
round-trip matching the browser scheme.

Follow-ups (scoped in the design doc): a CLI/visor consumer that supplies the UI
FS + built wasm; inlining the runtime Angular assets (i18n JSON + flag/logo PNGs)
into a virtual FS override.js serves (today a generated file shows untranslated
strings + broken images); and headless-browser validation of a real bundle.
…ith derived key (skycoin#3201)

Wires the standalone generator (skycoin#3200) into a usable command and adds one-way
key derivation.

`skywire cli hv gen` produces a self-contained, serverless hypervisor.html: the
embedded Angular UI + the (--wasm) js/wasm dmsg client + override.js + config,
all inlined, openable from file://. Default = standalone (visors dial in);
--viewer-pk = dial a remote hypervisor.

Identity:
  - DeriveStandaloneKey(parentSK, index): HMAC-SHA256(parentSK, label||index) →
    GenerateDeterministicKeyPair. ONE-WAY (a cracked standalone key can't expose
    the parent), deterministic (regenerable from the one root key — no separate
    backup), and indexed (distinct standalone identities from one root). `-c
    <config> [--index N]` derives from the visor's sk and prints the derived PK
    (the PK to set as the visors' remote hypervisor). `--sk` overrides; neither =
    ephemeral.
  - --password encrypts the baked-in key (PBKDF2/AES-GCM, the format override.js
    decrypts); the plaintext key never touches the file. A bare (unencrypted) key
    warns.

Plumbing: pkg/wasmhv embeds wasm_exec.js (WasmExecJS) so a generated file is
self-contained (must match the Go toolchain that built the wasm); pkg/visor
exports HypervisorUIFS() for the embedded Angular assets.

Validated end-to-end: `hv gen --wasm dmsg.wasm -c config --password ...` derives
the key and writes a 9.6 MB fully-inlined hypervisor.html (21.6 MB wasm gzipped
to ~6 MB) — no external script/style refs, encrypted key only (no plaintext
leak). Tests cover the derivation (deterministic/indexed/parent-bound/one-way).

Follow-ups: inline the runtime Angular assets (i18n JSON + flag/logo PNGs) so a
generated file isn't untranslated + missing images; headless-browser validation;
and the optional tagged 6 MB wasm embed + --extract-wasm for an out-of-box / in-UI
flow.
…ml (skycoin#3202)

A generated standalone file previously showed untranslated strings + broken
images: the Angular app fetches its runtime assets (i18n JSON, flag/logo
images, fonts under assets/) at load, and from file:// there's no backend to
serve them.

The generator now inlines everything under assets/ into a JS map
(self.__SKYWIRE_ASSETS__ = {"/assets/...":{ct,b:base64}, ...}), and override.js
serves them locally: a new serve() checks assetResponse() FIRST — before
ensure() — so assets resolve instantly with no dmsg round-trip, and the UI
renders translations + images even before the dmsg client is up. Non-asset
paths (/api/*) fall through to the dmsg/hvApi dispatch as before. Works in both
standalone and viewer modes (inlined assets beat a remote fetch either way).

Cost: base64 adds ~33% over the raw asset size (~3.5 MB assets → ~4.7 MB), so a
generated file grows accordingly; gzip + excluding the country-flag PNGs are
noted size levers. Tests assert the asset map is inlined with correct path +
content-type + base64.

Remaining for a fully-working file: headless-browser validation of a real
bundle (this covers structure).
…keys (skycoin#3203)

Gives the visor config a small wallet-like keyring so the standalone hypervisor
uses a deterministically-derived child of the visor key instead of a random,
unrelated one — and so derived keys are tracked (no index reuse), in the spirit
of a skycoin deterministic wallet.

- cipher.DeriveChildKey(parentSK, label, index): the shared one-way derivation
  primitive — seed = HMAC-SHA256(parentSK, label||index) → GenerateDeterministic
  KeyPair. One-way (a leaked child can't expose the parent), deterministic
  (regenerable, no separate backup), label+index namespaced.
  wasmhv.DeriveStandaloneKey now delegates to it (output-preserving) and exports
  StandaloneKeyLabel.
- visorconfig.KeyRing / KeyEntry (config field "keyring"): wallet-shaped — type
  + next_index + entries{index,label,address,public_key,secret_key}. The
  skycoin address is derived from the public key. V1.MintKey(label) derives the
  next entry and advances next_index (the "derive a new address" op);
  DeriveKeyEntry re-derives a known entry read-only. Added KeyRing to the
  custom-UnmarshalJSON mirror (config_compat) so it round-trips.
- cli hv gen: with -c <config> it mints the next keyring key, records it, and
  flushes the config (prints the derived address + PK); --index N re-derives a
  known entry read-only (no write); --sk still overrides.

Validated end-to-end on a config copy: minting advances 0→1 with distinct
keys/addresses, the keyring round-trips through load→mint→flush, and the rest of
the config is preserved (sk/pk/launcher/dmsg/... intact). Tests cover the
derivation determinism + keyring mint/derive consistency with the shared
primitive. Deliberately minimal — fleet provisioning, xpub watch-only, and any
ledger ideas are explicitly out of scope pending a clear problem to solve.
…kycoin#3204)

The KeyRing's only purpose is the standalone-hypervisor identity keypair, not a
payment wallet, so the derived skycoin address was vestigial (a leftover of the
deferred wallet/ledger idea). Remove KeyEntry.Address + its derivation (and the
skycoin/src/cipher import); cli hv gen now prints just the derived PK.
…nt; split RPCClientDialer)

TinyGo HV groundwork (1/N): net/rpc transitively pulls net/http, which is broken
on the TinyGo js target. Two changes remove net/rpc from the cmd/dmsg-wasm graph:

- pkg/wasmhv/gobrpc.go: a minimal client speaking the EXACT net/rpc gob wire
  protocol (Request+args, Response+reply), so it talks to the visors' net/rpc
  server unchanged but imports no net/rpc. The core now uses it instead of
  rpc.NewClient. gobrpc_test.go drives a REAL net/rpc server over a pipe
  (incl. the error path + stream re-alignment) to prove wire compatibility.
- pkg/dmsg/noise: RPCClientDialer (the only net/rpc user) split into
  rpcdialer.go (//go:build !tinygo); net.go keeps the core Conn/Listener.

Normal build + tests unaffected; net/rpc verified gone from the tinygo graph.
…r TinyGo (6.5MB vs 21.6MB)

TinyGo HV groundwork (2/2): the only remaining blocker to a TinyGo-built wasm
hypervisor was net/http, broken on the TinyGo js target (roundtrip_js.go). This
removes net/http from the cmd/dmsg-wasm graph entirely, so it builds with TinyGo:
3.4x smaller (21.6MB -> 6.5MB; 5.6MB -> 2.4MB gzipped), small enough to embed by
default.

net/http was pulled by three things; each gets a build-tag split (native keeps
the existing net/http path; tinygo gets a net/http-free equivalent):

- DISCOVERY over dmsg (the keystone): dmsgclient.dmsgDiscClient implements
  disc.APIClient by speaking HTTP/1.1 straight over a dmsg stream (httpdmsg.go:
  a minimal, wire-correct HTTP/1.1 client — Content-Length, chunked, and
  Connection:close framing; httpdmsg_test.go validates it against a REAL
  net/http server). seeded.go's discovery upgrade is split into
  seeded_upgrade_{native,tinygo}.go. The net/http-free fallbackDiscClient is
  extracted to fallback_disc.go (untagged); the net/http-using bootstrap helpers
  + cobra flags move behind //go:build !tinygo (cli.go, cli_fallback.go,
  flags.go).

- WEBSOCKET client dial: coder/websocket pulls net/http via its Dial signature
  (*http.Response). ws_js_tinygo.go dials the browser's native WebSocket through
  syscall/js and adapts it to a net.Conn (full deadline-aware Read/Write/Close),
  flowing through the SAME makeClientSession+yamux path. ws_server.go splits the
  server-side ServeWS (http.Server) behind !tinygo. Only the tinygo+js+wasm combo
  is affected — native, std-Go wasm, and tinygo+wasip1/IoT keep coder/websocket
  (net/http compiles there).

- jsFetch viewer transport: split into fetch_{native,tinygo}.go; the tinygo path
  uses the new dmsgclient.FetchOverDmsg (net/http-free HTTP-over-dmsg round trip).

Builds verified: native (go build ./...), std-Go js/wasm, and
tinygo -target wasm. Tests + lint clean. No behavior change on existing builds.
…p (TinyGo)

Two more browser transports, both compile-verified under std-Go wasm AND TinyGo
wasm (final artifact 6.73MB, +0.2MB over the WS-only baseline):

- WebTransport dmsg carrier (wt_js_tinygo.go): dials a dmsg server's WT endpoint
  via the browser WebTransport API (syscall/js), pinning Server.CertHashWT in
  serverCertificateHashes — CA-free reach by bare IP, mirroring the native wt.go
  cert-hash model. One bidirectional WT stream → net.Conn → the same
  makeClientSession+yamux path as WS/TCP. The TinyGo WT stub splits: quic_stub.go
  keeps QUIC; wt_stub_tinygo.go covers non-browser (IoT) TinyGo; the browser gets
  the real dial. awaitJS() bridges JS promises to blocking Go.

- WebRTC DataChannel p2p (cmd/dmsg-wasm/webrtc_js.go): the first TRUE peer-to-peer
  browser transport — a direct DTLS+SCTP DataChannel between two leaves, no relay
  in the data path. dmsg is the signaling plane: offer/answer/ICE ride a dmsg
  stream (SignalChannel over *dmsg.Stream, port 47); the opened DataChannel is
  adapted to net.Conn. JS API webrtcDial(pk)/webrtcListen(cb). This realizes a
  browser p2p endpoint reachable BY PK through the mesh — a tab need not listen.

Design: docs/design/wasm-visor-p2p.md formalizes 'reachability != listening' — a
tab with one outbound transport + a TPD edge is a routable node serving skynet,
the dmsg model generalized to the routing layer. The WebRTC-as-mesh-transport +
wasm-visor integration (TPD registration, route-setup responder) is designed,
not built. None of the browser transports is runtime-validated in a browser yet
(compile-only); that is the next gate.
cmd/wasm-visor-probe is a build-only frontier check: what tinygo build accepts is
what's browser-portable. Measured frontier (go list -deps -tags tinygo):

  ✅ compile under TinyGo today: pkg/routing, pkg/visor/visorconfig
  ⛔ blocked by a small recurring set:
     quic-go  — the raw-socket networks (stcp/sudph/quic) in pkg/transport/network
     net/http — RF/TPD/AR discovery clients (the disc-over-dmsg pattern solves it)
     net/rpc  — appevent channel + router/cascade_source.go RSN oracle
     os/exec  — pkg/visor only: the app-SUBPROCESS model (the deepest gap)

docs/design/wasm-visor-p2p.md §7 adds the measured table + a 5-phase plan:
(1) dmsg-only network build, (2) net/http-free RF/TPD/AR, (3) tag out net/rpc →
routing+transport core in the browser; (4) in-process apps (the real fork, cf.
skycoin#2775); (5) browser persistence + cmd/wasm-visor assembly.

No production code touched — analysis + probe only.
…s framing

- Correct phase 4: in-process apps are NOT a fork. The launcher already has
  RunModeInternal (startInProcess, run-func in a goroutine) vs RunModeExternal
  (startExternal, the only os/exec). A wasm visor uses the internal launcher and
  tags out startExternal.
- New §8: which apps fit, by the rule 'pure mesh I/O OR a browser-exposed
  capability'. Fit: skychat, content/site hosting, file share, wallet-over-dmsg,
  CXO pubsub, capability bridge. Don't: skysocks/skysocks-client, VPN, port
  forwarding (raw sockets / TUN / local listen). Frame: a wasm visor is a
  personal present-when-you-are node — a CONSUMER of infrastructure apps via
  routes, not a provider. Start with skychat + content hosting.
The browser transports + in-wasm HV compile but need RUNTIME validation in a tab
(the gate compile checks can't cover). This adds the harness to do it:

- make tinygo-dmsg-wasm: build dmsg.wasm with TinyGo (~6.5MB) + stage the page.
- cmd/dmsg-wasm/serve.go (//go:build ignore): tiny no-Python static server that
  sets Content-Type: application/wasm so instantiateStreaming works.
- cmd/dmsg-wasm/index.html: extend the existing harness with sections for the
  in-wasm hypervisor (serveHypervisor / hvApi) and WebRTC p2p (webrtcListen /
  webrtcDial / send); fix the fetch body to TextDecode the binary Uint8Array.

Run: make tinygo-dmsg-wasm && go run cmd/dmsg-wasm/serve.go → http://localhost:8085/
Validated: build + serve + correct wasm MIME. Browser-click validation against a
live dmsg server is the manual gate this enables.
…browser

First runtime finding from the harness: the TinyGo wasm failed to instantiate
('gojs runtime.getRandomData: function import requires a callable'). TinyGo's
wasm_exec.js provides random via WASI random_get but OMITS the gojs
runtime.getRandomData import the Go runtime calls to seed itself on the crypto
path — so any crypto-using TinyGo wasm (ours generates keys) won't load. tpviz
works only because it uses no crypto/rand.

Fix in the loader: inject runtime.getRandomData ONLY when absent (TinyGo), so the
standard-Go build — whose wasm_exec.js already defines it via a different
sp-based ABI — is untouched. TinyGo passes a []byte as (ptr,len,cap) over
exports.memory, so the shim is crypto.getRandomValues(new Uint8Array(mem,ptr,len)).
Also switch to fetch+arrayBuffer instantiate (MIME-independent) and add a .catch.

Verified headless via node + wasm_exec.js: main() runs, all 8 skywireDmsg methods
register. (Candidate for an upstream TinyGo wasm_exec.js fix.)
@0pcom

0pcom commented Jun 22, 2026

Copy link
Copy Markdown
Owner Author

Browser runtime validation — core path ✅

Validated the TinyGo wasm in a real browser against a live dmsg server (after fixing one runtime gap, see below). Both net/http-removal halves now run, not just compile:

surface result proves
wasm load skywireDmsg ready (8 methods) the build runs in-browser
connect got a PK, session established net/http-free disc client (the keystone) + WebSocket carrier
dial stream open to a real visor dmsg stream over the browser WS session
fetch HTTP 200 + real /health JSON FetchOverDmsg — net/http-free HTTP/1.1-over-dmsg
serveHypervisor + hvApi HTTP 200 [] the wasmhv core runs under TinyGo (the gob-RPC client that replaced net/rpc)

Runtime gap found + fixed (4b06136)

TinyGo's wasm_exec.js omits the gojs runtime.getRandomData import the Go runtime needs to seed itself on the crypto path, so a crypto-using TinyGo wasm fails to instantiate. Fixed in the loader by injecting it only when absent (std-Go untouched). Candidate for an upstream TinyGo wasm_exec.js fix.

Still foundation-only (not yet validated, needs two tabs / a peer)

  • inbound reachability reciprocal (listen in one tab, dial from another)
  • WebRTC DataChannel (webrtcListen / webrtcDial between two tabs)

These are the transport foundations the PR labels experimental; the core net/http-free path is browser-proven and the PR is mergeable on that basis.

0pcom added 8 commits June 21, 2026 19:51
…for 2-tab orchestration

serve.go grows a control bridge so an external operator (or the assistant, via
curl on the same host) can drive the browser tab(s) headlessly — enough to
orchestrate two wasm visors against each other (the inbound-reachability + WebRTC
paths that need two endpoints):

- GET  /ctl/events?tab=ID  : SSE; the tab subscribes to receive commands
- POST /ctl/result         : the tab returns a command's result (correlated by id)
- POST /ctl/log?tab=ID     : the tab streams its log lines; GET reads them back
- GET  /ctl/tabs           : list connected tabs
- POST /ctl/cmd?tab=ID     : queue {action,args}, push to the tab, await its result

index.html grows a control client (EventSource + a dispatch over the skywireDmsg
API: connect/dial/listen/send/fetch/serveHypervisor/hvApi/webrtcDial/Listen/rtcsend),
mirroring logs to the server and keeping named stream/datachannel handles. No-ops
if no control server is present, so the manual harness still works. Stdlib-only
(SSE), no new deps.

Validated headless: a node fake-tab + curl drove connect/webrtcDial and read the
result + log buffer back.
…ployment STUN

Browser validation of the WebRTC path surfaced + fixed real bugs and proved the
signaling end-to-end:

- FIX: dmsgSignalChannel.recv did `return m, json.Unmarshal(b, &m)` — Go leaves
  the result-read vs call order unspecified, and TinyGo reads m (empty) BEFORE
  Unmarshal mutates it, so every signaling message decoded blank. Decode then
  return. (std-Go happened to work; TinyGo exposed it. Confirmed by probe.)
- FIX: trickle-ICE candidate buffering — addIceCandidate before the remote
  description is set silently drops candidates. Buffer until remoteSet, then flush.
- Instrumentation: WebRTC signaling/ICE progress routed to window.__wrtclog so the
  control bridge surfaces offer/answer/candidate/ICE-state/datachannel milestones.
- WebRTC ICE now uses the deployment's own STUN servers (deployment.Prod.StunServers,
  exposed to JS as skywireDmsg.stunServers) — no third-party STUN. TODO: a
  visor-generated UI must inject THAT visor's config, not the embedded default.

Harness: control bridge gains stable per-tab ids (sessionStorage), a `reload`
command, a /ctl/clear endpoint, and no-cache headers, so reloads can be driven
remotely. serve.go silences the favicon 404.

Validated in-browser between two tabs: connect, dmsg dial/listen + bidirectional
data, HTTP-over-dmsg, in-wasm hypervisor, and WebRTC signaling + ICE negotiation
(offer/answer/candidates exchange; ICE reaches 'checking'). WebRTC connection
COMPLETION fails in the same-machine/same-NAT case (mDNS host candidates + srflx
hairpin) — a topology limitation, the documented ICE open question, not a code bug.
… from it)

The seeded dmsg client (browser/standalone) connected to a server but never
registered its entry in dmsg-discovery — peers could only reach it via the
shared-server fallback. Root cause: the registering-fallback resolved the
client's OWN pk from a synthetic DIRECT-client entry, so both initilizeClientEntry
and updateClientEntry saw a fake 'already registered' entry and skipped the POST.

Fix (per operator guidance — don't use a direct client for discovery queries):
- pkg/dmsg/dmsgclient/seeded_disc.go: seededDiscClient talks to the REAL discovery
  for register/resolve/AvailableServers, keeping a shortcut ONLY for the discovery
  PK + seed servers (recursion-avoidance + bootstrap). Wired into the TinyGo
  upgrade (native keeps the proven fallback). Own pk now resolves against real
  discovery (404 when absent) → the register-fresh-entry path fires.
- pkg/dmsg/dmsg/entity_common.go: setDiscoveryClients clears lastPushedSrvPKs and
  nudges a re-publish, so swapping the bootstrap disc client for the real one
  actually (re)registers instead of being short-circuited by the SamePubKeys guard.
- discdmsg.go: map the 404 body to disc.ErrKeyNotFound so isEntryNotFound matches.
- MinSessions 1→2 (resilience; browser is still capped by WS-capable server count).
- jslog hook (window.__skylog) + harness control-bridge reconnect fix, /ctl/clear,
  no-cache headers, and a READY signal — so reloads + tests can be driven remotely.

Validated in-browser (autonomous control bridge): the TinyGo wasm client registers
(PostEntry 200 'wrote a new entry'); a discovery lookup of its own PK returns the
full entry (200).
…) behind !tinygo

Phase 1 of the visor wasm port: drop the browser-irrelevant raw-socket transport
carriers from the TinyGo graph so dmsg (and the future webrtc) remain.

- quic.go + quic_identity.go: //go:build !tinygo (quic-go + pkg/skyquic).
- sudph.go + stun_client.go: //go:build !tinygo (kcp-go + pfilter, which pull
  quic-go transitively; go-stun).
- The QUIC/SUDPH constructors are routed through build-tagged makeQuicClient /
  makeSudphClient helpers (real on native, unsupported-error stubs on TinyGo), so
  client.go's MakeClient stays untagged and the type-switch compiles on both.
- stcp/stcpr stay untagged (raw net compiles under TinyGo).

Native unchanged (the tagged files stay in native builds) — go build ./... green.
Remaining TinyGo blockers in pkg/transport/network: addrresolver (net/http +
packetfilter via the AR machinery in client.go's ClientFactory.ARClient) and
appevent (net/rpc) — phases 2-3. See docs/design/wasm-visor-p2p.md §7.
…twork→transport→visor (needs a per-layer restructure)
@0pcom

0pcom commented Jun 22, 2026

Copy link
Copy Markdown
Owner Author

Retargeting to upstream skycoin/skywire.

@0pcom 0pcom closed this Jun 22, 2026
0pcom added a commit that referenced this pull request Jul 7, 2026
…2398)

* docs(dht): update 26-DHT.md to current implementation; add SKYWIRE_DHT.md

26-DHT.md was missing several merged PR skycoin#2397 changes:
- MaxValueSize 16 KiB → 64 KiB and the rationale
- methodGetItems (Tag 5) and methodPutBatch (Tag 6) RPCs
- GetItems pagination semantics with Seq cursor
- Full-node reconcile loop (pull + push) and FullNodeAdvert
- 4 MiB rpcCall message cap
- Persistence backends (memory / bbolt / Redis)
- CLI surface (`dht status/get/put/list/sync/full-node`) and which
  commands are useful for full vs non-full nodes
- Default constant values from pkg/dht/config.go (PublicPoolSize=5000,
  RateLimitPerPK=50, ItemTTL=2h, RefreshInterval=30s, MaxItems=10000)

Adds SKYWIRE_DHT.md, a descriptive companion to the normative spec.
Compares Skywire's DHT side-by-side with BitTorrent BEP5/BEP44,
libp2p Kademlia, Ethereum discv5, and Tor v3 onion descriptors across
identity, routing table, item model, transport, eclipse/Sybil
resistance, and bootstrap/reconciliation. Frames the design choices
that diverge from textbook Kademlia (secp256k1 over ed25519,
trust tiers, full-node mode, active reconcile, hybrid HTTP fallback)
in the context of *why* Skywire needs them rather than treating
Kademlia as the ceiling.

Also documents what the implementation does NOT do (no CAS, no
republish-on-receive, no IP-level rate limiting, single 64 KiB cap)
and an honest list of where the implementation is still rough —
hourly reconcile cadence, PutMirror admission gating, missing
metrics, debugging gaps.

* cli: dht peers and dht reconcile subcommands

Two CLI gaps surfaced in the DHT audit:

1. 'skywire cli visor dht status' shows the routing-table peer count
   but no way to actually inspect WHICH peers are in the K-buckets.
   Debugging a "DHT seems connected but lookups miss" problem required
   log-grepping. New `dht peers` dumps the full table sorted by bucket
   index then last-seen, with --json for machine consumption.

2. The hourly fullNodePullLoop reconciles automatically, but there
   was no way to force a reconcile against a specific peer for
   testing or to push fresh local state to a known-stale peer
   immediately. New `dht reconcile <full-node-pk>` runs one
   pull+push pass synchronously and reports (pulled, pushed) counts.

Both wire through the same RPC layer:
- pkg/visor/api.go: DHTPeers, DHTReconcile in API interface
- pkg/visor/api_dht.go: DHTPeerInfo struct, DHTPeers/DHTReconcile
  visor methods
- pkg/visor/rpc_dht.go: DHTPeers/DHTReconcile RPC handlers, with
  DHTReconcileResult carrying the pulled+pushed counts on the wire
- pkg/visor/rpc_client.go: matching client methods
- pkg/visor/rpc_client_mock.go: not-supported stubs

Reconcile safety: the receiver-side PutMirror handler stores any
signed item without distance/admission gating, so pushing to a
non-full-node would silently overflow its store. New
dht.Node.IsTrustedFullNode reports whether a PK is in
BootstrapPKs ∪ FindAdvertisedFullNodes (signed self-attestation).
DHTReconcile rejects untrusted PKs with a clear error before
opening the dial. dht.Node.Reconcile is now the exported wrapper
around reconcile (still lowercase) with the safety contract spelled
out in its doc comment.

Verified live on the dev visor:
- 'dht peers' returned 18 entries across multiple bucket indexes
- 'dht reconcile <bootstrap-pk> --salt tp' pulled 1120 tp items in
  one pass
- 'dht reconcile <random-visor-pk>' rejected with "not a known full
  node (not in bootstrap config and no fresh fullnode advert)"

Updates 26-DHT.md's CLI table to reflect the two new commands.

* cli: route calc --source tpd|dht|auto to compare DHT vs TPD coverage

The audit identified 'route calc' as a place where the local DHT
could substitute for the centralized TPD fetch, useful for full
nodes that already mirror the network's transport graph. Adds a
--source flag (default tpd, preserving current behavior):

  --source tpd  : fetch /all-transports from the deployment TPD
                  (existing chain: visor RPC → DMSG direct → HTTP)
  --source dht  : build the graph from the local DHT's "tp" salt
                  via DHTGetAll. Only bare-entry format contributes
                  (the deployment-pushed compact [{r,t,l,b}] format
                  omits the source PK in its value — only the storage
                  target hash carries it, and SHA256 isn't invertible
                  — so those entries are skipped)
  --source auto : try DHT first, fall back to TPD if DHT yielded
                  fewer than 10 entries

Tested live on the dev visor (full node, recently reconciled):

  route calc <pk> --source dht  --max 4 -c 0 → 878 routes
  route calc <pk> --source tpd  --max 4 -c 0 → 4828 routes

The 18% DHT coverage reflects the fraction of network visors that
have published their transports as TPDAdapter bare entries (not yet
fully rolled out). When more visors dual-publish — or when the
compact-format publishers start emitting an explicit subject PK —
DHT-source coverage will catch up.

Updates 26-DHT.md to document the flag, including the
compact-format limitation and why auto's threshold is 10.

* cli: route calc --source dht now recovers compact-format tp entries

The compact tp format ([{r, t, l}]) emitted by deployment-side
mirror publishers was previously skipped by --source dht because it
omits the source PK in its value (only the storage-target hash
encodes it, and SHA256 is one-way).

Recovery is possible via cross-reference: every visor that publishes
to both the dmsg and tp salts has its PK in the dmsg entry's
`static` field. Since target = SHA256(pk || salt), the dmsg salt
index gives us pk → tp-target. We hash every dmsg entry's PK with
the tp salt to build a target → PK map, then resolve compact tp
entries through that map.

Synthetic transport IDs are generated from the (srcPK, rPK, type)
tuple via transport.MakeTransportID — deterministic, so re-runs
against the same data produce stable IDs.

Also handles []transport.SignedEntry format (previously failed to
unmarshal silently).

Verified live on the dev visor:

  Before: --source dht returned 878 routes (max=4, count=0)
  After:  --source dht returned 2674 routes — 3× coverage

For comparison, --source tpd returns 4828 routes against the same
network state. DHT coverage is now ~55% of TPD (up from ~18%)
without changing any publisher; the residual gap is entries from
visors that publish only one of dmsg/tp salts.

Updates 26-DHT.md to describe all three supported formats and the
recovery mechanism.

* dht: chunk hub-edge tp publishes; consume compact-envelope format

Two of the three follow-up fixes from the audit (Fix 2b — visor
dual-publish of dmsg entries — turned out to already be implemented
via HybridDiscClient.PostEntry/PutEntry mirroring to both DHT and
HTTP, plus the dhtPublishLoop's 60s self-publish; no change needed).

== Fix 1: compact-envelope consumer support

The deployment-side mirror's compact tp format is [{r, t, l}] —
single-letter tags omit the source PK in the value (it's only in
the storage-target hash, and SHA256 isn't invertible). The 86d6d74
recovery uses the dmsg-salt's `static` field as a PK→target index;
that helps but only resolves visors that publish to both salts.

Adds support for an envelope shape that any future publisher can
emit instead:

  {"s": "<source pk hex>", "ts": [{"r","t","l"}, ...]}

The source PK is explicit (one PK per visor, not per transport, so
hub edges don't blow the size budget) and consumers can decode
without cross-referencing anything.

cmd/skywire-cli/commands/route/calc.go's fetchAllTransportsFromDHT
now tries four shapes in order: bare []transport.Entry, signed
[]transport.SignedEntry, compact-envelope {s, ts}, compact-array
[{r,t,l}] (with dmsg cross-reference recovery). Common
compactToEntry helper synthesizes Entry from a compact row plus a
known source PK.

== Fix 3: chunk hub-edge tp publishes

TPDAdapter.putEntries was a single Put with an early "list too
large" error if marshaling exceeded MaxValueSize (64 KiB). Hub
edges with 250+ transports hit this and dropped silently after the
log. Now chunks at tpChunkSize=200 entries per item; chunk 0 keeps
the bare "tp" salt for back-compat, chunks 1+ go to "tp:1",
"tp:2", … GetTransportsByEdge iterates chunks until missing or
empty.

prevChunkCount (atomic) on TPDAdapter remembers the last publish's
chunk count so a shrunk transport list publishes "[]" tombstones to
now-unused chunk salts; readers stop at the first empty chunk for
i>0 so they never see stale tail.

decodeTpItem extracted from GetTransportsByEdge handles per-chunk
shape decoding (bare/signed) — no behavior change for chunk 0,
just factored for reuse across chunks.

== Spec docs

26-DHT.md: documents all four tp shapes (consumer side) and the
chunking scheme (publisher side).

* docs: move SKYWIRE_DHT.md to project root + refresh post-merge

SKYWIRE_DHT.md is a descriptive comparison document, not a normative
spec — it doesn't belong in skywire-specs/specifications/. Moved to
the project root next to README.md, AUTO_UPDATE.md, NETWORK_DATA.md
and the other top-level reference docs. The normative spec stays at
skywire-specs/specifications/26-DHT.md.

Refreshed for commits that landed after the original write:

- Capsule summary notes hub-edge tp chunking past the value cap
- Rough-edge #3 updated: 'dht peers' and 'dht reconcile' CLI now
  ship, gated to IsTrustedFullNode peers
- Rough-edge #6: chunking is implemented for tp salt, scoped to
  publishers that need it; other salts may need the same later
- Rough-edge #7 expands the data-format-coexistence note from svc
  to the four tp shapes (bare, signed, compact-array,
  compact-envelope) that route calc --source dht now decodes

See-also links updated to reach into skywire-specs/specifications/
since SKYWIRE_DHT.md is now a directory level above them.

* dht: TPDAdapter & SvcAdapter accept all wire shapes; unify via DecodeTpItem

Pushes multi-shape tolerance from CLI / autoconnect down into the
adapter layer so every reader benefits, not just the diagnostic
paths that previously knew about it.

== TPDAdapter

decodeTpItem (called by GetTransportsByEdge for every per-PK lookup
through the visor's transport manager) was bare + signed only:

  1. Bare:    []transport.Entry             ✓
  2. Signed:  []transport.SignedEntry       ✓
  3. Compact-array:    [{r, t, l}]          ✗ silent miss
  4. Compact-envelope: {s, ts: [{r, t, l}]} ✗ silent miss

Compact entries on the network (~5% of tp salt today, plus whatever
external publishers emit) were invisible to the visor's routing layer
even though the CLI's `route calc --source dht` happily decoded them.
That meant the visor's HybridTPDClient.GetTransportsByEdge fell
through to TPD HTTP unnecessarily.

decodeTpItem now accepts all four shapes. Compact-array uses the
caller's pk argument as the source PK (which GetTransportsByEdge
always knows — it IS the lookup key). Compact-envelope reads `s`
from the value. compactTpEntry, compactEnvelope, and compactToEntry
moved into pkg/dht/tpd_adapter.go.

Public DecodeTpItem(v, srcPK) wrapper exposed for cmd/skywire-cli
to reuse — calc.go's fetchAllTransportsFromDHT is now a tight
~25-line loop over rows that just delegates the per-shape decoding
to the adapter package. -110 lines of duplication.

== SvcAdapter

LookupAll only handled []servicedisc.Service and errored on a
single Service object. autoconnect.fetchPubAddresses had been
inlining a dual-shape parse to work around this; LookupAll now does
the same so any other caller through the adapter sees the legacy
entries too.

== Verification

`route calc <pk> --source dht --max 4 -c 0` against the network:
  Before unification: 2674 routes  (55% of TPD's 4828)
  After unification:  4175 routes  (74% of TPD's 5636)

TPD count fluctuates between runs (network state). The improvement
ratio is consistent — same code now succeeds for the per-PK DHT
path that the visor uses, not just the CLI scan.

* docs: add CXO_VS_DHT.md retrospective

Captures the architectural retrospective: could the work the DHT
does today have been done with the CXO TreeStore primitive that's
already in the tree? Mostly yes. Should it have been? Probably yes.
Should we rip out the DHT? No.

The doc walks through:

- Side-by-side mapping of every property the DHT provides for the
  discovery use case to its CXO equivalent
- Where the DHT genuinely differs (cross-publisher lookup without
  prior subscription) and why we don't actually use that primitive
  in production today (every visor either runs a full node or falls
  through to HTTP)
- Where CXO is the better fit (history, dedup, no-cap merkle DAG,
  schema registry vs silent shape coexistence)
- What we actually built on this PR — every gap-fixing mechanism
  (full-node reconcile, advertised full-node discovery, hub-edge
  chunking, multi-shape tolerance, DHT→HTTP mirror) brings the DHT
  closer to CXO's model, not further from it
- Forward path: existing DHT salts stay, new decentralized state
  defaults to CXO, the framing of "DHT is the future" softens

Lands at the project root next to README.md / SKYWIRE_DHT.md /
NETWORK_DATA.md / etc. Not a normative spec — a retrospective.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant