diff --git a/docs/proxy-status-and-interstitial.md b/docs/proxy-status-and-interstitial.md new file mode 100644 index 0000000000..dca5100c6c --- /dev/null +++ b/docs/proxy-status-and-interstitial.md @@ -0,0 +1,80 @@ +# Proxy status hosts + HTTPS interstitial + +Two related features on the embedded-web-proxy surface (`dmsg_web`, +`skynet_web`, and the `skysocks-client` tunnel they chain to). + +## 1. Interstitial over HTTPS + +The branded "building a route over the mesh…" interstitial +(`pkg/proxyinterstitial`) is injected by the resolving proxies' SOCKS5 `Dial` +callback when a route to the target is still warming. For plaintext HTTP it is +spliced in directly; for HTTPS it must ride a locally-terminated TLS session +(TLS MITM) because a raw-TLS tunnel can't carry an HTML page. + +TLS termination uses a **name-constrained** local CA (`pkg/skynetca`) that can +only mint leaves for `.skynet` and `.dmsg`. The live bug was that the HTTPS +interstitial path attempted a mint for **every** TLS-port dial failure — +including clearnet HTTPS forwarded to the upstream `skysocks-client` — which the +CA can never cover, producing a `host does not match permitted suffix` error on +every such request. + +Fix: `skynetca.Permits(minter, host)` (a non-breaking optional interface, +`HostPermitter`, implemented by `CachedMinter`) reports whether the CA can cover +a host **without** minting. The HTTPS-interstitial path in both resolving +proxies now gates on it: + +``` +case cfg.TLSMITM && isTLSPort(port) && skynetca.Permits(cfg.LeafMinter, origHost): + // mint leaf, MITM-terminate, serve interstitial HTML over TLS +``` + +So a `.skynet`/`.dmsg` HTTPS target reliably renders the interstitial over TLS, +while a clearnet HTTPS target (which cannot be MITM'd by a name-constrained CA +anyway) cleanly falls through to the real error instead of a per-request log +spam. The page itself gained a footer with a deep-link to the surface's status +host (below). + +## 2. Per-proxy status hosts + +Each proxy serves a read-only diagnostic page at a reserved, well-known host +**through itself**, mirroring the existing in-process `home.` host: + +| host | surface | underlying app | +|---------------------|-----------|-------------------| +| `http://status.dmsg/` | dmsg | `dmsgweb` | +| `http://status.skynet/` | skynet | `skynetweb` | +| `http://status.skysocks/` | skysocks | `skysocks-client` | + +`pkg/proxystatus` owns the surface taxonomy, host matcher (`Match`), the +in-process HTTP responder (`ServeConn`, same net.Pipe trick as +`serveHomeInProcess`), the read-only `Snapshot` shape + `Provider` interface, and +the HTML renderer. Both resolving proxies' `Dial` callbacks check `Match(host)` +**before** suffix resolution / upstream forwarding, so any of the three hosts is +reachable through either proxy (a browser typically points at one). + +The visor implements `proxystatus.Provider` +(`pkg/visor/embedded_proxystatus.go`) entirely on **existing** read APIs: + +- **logging** — `Visor.LogsSince(app)` tails the app's log store; +- **mux view** — `Visor.RouteGroupMuxInfo(app)` gives the same per-leg + bandwidth/RTT/retransmit telemetry `cli proxy mux plot` renders, drawn as a + static per-leg bandwidth-share table (meta-refreshes to stay live); +- **running** — `procManager.ProcByName(app)`. + +MVP status vs. scaffold: + +- **`status.skysocks`** is the fully-realized MVP (logs + live mux view for the + route group where multiplexing actually happens). +- **`status.dmsg` / `status.skynet`** share the identical page; their mux + section is empty until/unless their route group is tagged for + `RouteGroupMuxInfo`. +- **route/transport events** render an empty section today — the collection + buffer is the scaffolded extension point. + +### Extension seam: route control + +The MVP is deliberately read-only. The page renders a disabled "route control" +section, and `Snapshot`/`Provider` are shaped so control lands additively: add a +mutating method to `proxystatus.Provider` and implement it on the existing visor +mux-reshape API (`AddMuxRoute` / `RemoveMuxRoute` / `SetMuxMode`) plus dmsg relay +selection — no wire reshape, no new plumbing in the proxies. diff --git a/pkg/dmsgweb/runtime.go b/pkg/dmsgweb/runtime.go index 32831d44c4..98260f5e81 100644 --- a/pkg/dmsgweb/runtime.go +++ b/pkg/dmsgweb/runtime.go @@ -40,6 +40,7 @@ import ( "github.com/skycoin/skywire/pkg/dmsg/ioutil" "github.com/skycoin/skywire/pkg/logging" "github.com/skycoin/skywire/pkg/proxyinterstitial" + "github.com/skycoin/skywire/pkg/proxystatus" "github.com/skycoin/skywire/pkg/skynetca" "github.com/skycoin/skywire/pkg/skynetweb" ) @@ -151,6 +152,13 @@ type Config struct { DirectClient *dmsg.Client // DirectServerPKs is the set of destination PKs to dial via DirectClient. DirectServerPKs map[cipher.PubKey]struct{} + + // StatusProvider, when non-nil, enables the reserved in-process status hosts + // (http://status.dmsg/ etc.) served through this proxy — a read-only + // diagnostic page (logs + route/transport events + per-leg mux view) for a + // surface. Nil disables the status hosts (they fall through to a normal + // resolve / upstream forward). See pkg/proxystatus. + StatusProvider proxystatus.Provider } // DmsgTarget is a (publicKey, dmsgPort) pair used in fixed-mapping mode. @@ -312,6 +320,22 @@ func serveSOCKS5Direct(ctx context.Context, log *logging.Logger, dmsgC *dmsg.Cli origPort = "80" } + // Reserved status hosts (http://status.dmsg/ etc.): served in-process, + // never dialed out — the read-only diagnostic page for a surface. + // Checked before suffix resolution / upstream forwarding so any of the + // three status hosts is reachable through this proxy. Recognized only on + // plaintext HTTP (a status page can't ride a raw-TLS tunnel). + if cfg.StatusProvider != nil && proxyinterstitial.ShouldServe(origPort) { + if surface, ok := proxystatus.Match(origHost); ok { + snap, serr := cfg.StatusProvider.StatusSnapshot(surface) + if serr != nil { + snap = proxystatus.Snapshot{Surface: surface, Note: "status unavailable: " + serr.Error()} + } + log.WithField("surface", string(surface)).Debug("SOCKS5 → serving in-process proxy status page") + return &tcpAddrConn{Conn: proxystatus.ServeConn(proxystatus.Render(snap))}, nil + } + } + // On a transient failure (dmsg route/session still warming, or a // not-yet-ready upstream like a booting skysocks-client) for a // plaintext-HTTP request, answer with a branded auto-refreshing @@ -334,12 +358,16 @@ func serveSOCKS5Direct(ctx context.Context, log *logging.Logger, dmsgC *dmsg.Cli log.WithField("host", target).WithField("err", dialErr). Debug("SOCKS5 → serving branded route interstitial") conn, dialErr = proxyinterstitial.Conn(target, proxyinterstitial.StatusLine(dialErr), "dmsg", false), nil - case cfg.TLSMITM && isTLSPort(origPort, cfg.TLSPort): + case cfg.TLSMITM && isTLSPort(origPort, cfg.TLSPort) && skynetca.Permits(cfg.LeafMinter, origHost): // HTTPS request whose route is still warming: terminate the // browser's TLS locally with a per-host leaf and serve the // interstitial HTML over it — same MITM path as a warm TLS // dial, but the "upstream" is the fixed interstitial responder. - // If minting fails, fall through to the real error. + // Gated on Permits so a clearnet-HTTPS request (whose host the + // name-constrained CA can't cover) falls straight through to the + // real error instead of a guaranteed "does not match permitted + // suffix" mint failure on every attempt. If minting still fails, + // fall through to the real error. leaf, lerr := cfg.LeafMinter.For(origHost) if lerr != nil { log.WithField("host", target).WithField("err", lerr). diff --git a/pkg/proxyinterstitial/interstitial.go b/pkg/proxyinterstitial/interstitial.go index fdd87dbe5b..2cb39a30b9 100644 --- a/pkg/proxyinterstitial/interstitial.go +++ b/pkg/proxyinterstitial/interstitial.go @@ -98,6 +98,16 @@ func Page(target, detail, mechanism string, isError bool) string { // the exit is already selected, so the work is route-building, not // "finding an exit". The fetch step names the concrete mechanism. fetchStep := `
  • Fetching the page over ` + html.EscapeString(mech) + `
  • ` + + // Footer: a subtle brand line, plus a "view proxy status" affordance that + // deep-links to this surface's reserved diagnostic host (served in-process by + // the same proxy — see pkg/proxystatus). Only a concrete mechanism has a + // status host; the generic "skywire" fallback shows just the brand line. + footer := `
    routed privately over the skywire mesh` + if h := statusHost(mech); h != "" { + footer += ` · proxy status ›` + } + footer += `
    ` return `` + `` + refreshMeta + @@ -115,9 +125,23 @@ func Page(target, detail, mechanism string, isError bool) string { `` + retry + hostBlock + + footer + `` } +// statusHost returns the reserved status host for a mechanism label +// ("skysocks" → "status.skysocks"), or "" for the generic "skywire" fallback +// (which has no dedicated surface). Kept local so this package stays +// dependency-free; the value mirrors pkg/proxystatus.Host. +func statusHost(mech string) string { + switch mech { + case "skysocks", "dmsg", "skynet": + return "status." + mech + default: + return "" + } +} + // skywireCloudPNG is the official Skycoin/Skywire striped-cloud brand mark // (the cloud-only icon, no wordmark). It is the exact same asset the Angular // manager UI ships as assets/img/skywire-icon.png, copied into this package so @@ -157,6 +181,8 @@ const css = `:root{--bg:#0b0d17;--fg:#c7cbe6;--muted:#7a80a8;--accent:#7c83ff;-- `.btn{display:inline-block;margin-top:18px;padding:8px 20px;border:1px solid var(--accent);border-radius:9px;background:transparent;color:var(--accent);font:inherit;font-size:13px;cursor:pointer}` + `.btn:hover{background:var(--accent);color:#0b0d17}` + `#mesh-host{display:block;margin-top:14px;font:12px/1.4 ui-monospace,SFMono-Regular,monospace;color:var(--muted);opacity:.75;word-break:break-all}` + + `.foot{margin-top:16px;padding-top:12px;border-top:1px solid var(--line);font-size:11.5px;color:var(--muted)}` + + `.foot a{color:var(--accent);text-decoration:none}.foot a:hover{text-decoration:underline}` + `.err #mesh-title{color:var(--err)}.err .sp{display:none}` // httpResponse wraps the HTML in a minimal HTTP/1.1 response. Connection:close diff --git a/pkg/proxyinterstitial/interstitial_test.go b/pkg/proxyinterstitial/interstitial_test.go index b3b0ab7839..918d748c8f 100644 --- a/pkg/proxyinterstitial/interstitial_test.go +++ b/pkg/proxyinterstitial/interstitial_test.go @@ -43,6 +43,20 @@ func TestPageEscapesTarget(t *testing.T) { } } +func TestPageStatusFooter(t *testing.T) { + // A concrete mechanism links to its reserved status host. + for _, mech := range []string{"skysocks", "dmsg", "skynet"} { + p := Page("host."+mech, "", mech, false) + if !strings.Contains(p, "http://status."+mech+"/") { + t.Errorf("mechanism %q: page missing status-host link", mech) + } + } + // The generic fallback has no dedicated surface, so no status link. + if p := Page("host", "", "", false); strings.Contains(p, "http://status.") { + t.Error("generic mechanism should not link a status host") + } +} + func TestConnServesHTTP(t *testing.T) { c := Conn("host.skynet", "", "skynet", false) // Write (the browser's request) is discarded but must not error. diff --git a/pkg/proxystatus/provider.go b/pkg/proxystatus/provider.go new file mode 100644 index 0000000000..b29ba90b5e --- /dev/null +++ b/pkg/proxystatus/provider.go @@ -0,0 +1,146 @@ +// Package proxystatus pkg/proxystatus/provider.go c4-app-web +// +// A read-only, self-contained diagnostic page each native mesh proxy serves at +// a reserved, well-known host through itself — the proxy counterpart of the +// branded route interstitial (pkg/proxyinterstitial). Hitting one of the +// reserved hosts through a resolving proxy shows that surface's live state: +// +// - http://status.dmsg/ → the dmsg resolving proxy (dmsg_web) +// - http://status.skynet/ → the skynet resolving proxy (skynet_web) +// - http://status.skysocks/ → the skysocks-client SOCKS tunnel +// +// Injection model mirrors the resolver's reserved "home" host: the SOCKS5 Dial +// callback recognizes a status host BEFORE any suffix match / upstream forward, +// renders the page in-process (nothing is dialed out over the mesh) and splices +// the rendered HTTP response back to the browser via ServeConn. +// +// MVP scope is READ-ONLY: the three data sections a proxy operator wants when a +// route misbehaves — recent per-process log lines, route/transport events, and a +// mux-plot-style per-leg bandwidth/RTT view of the surface's route group (the +// same RouteGroupMuxInfo telemetry `cli proxy mux plot` renders in the +// terminal). Route CONTROL (change/select routes, pick a dmsg relay) is left as +// an explicit, clearly-marked seam — see render.go's control section and the +// Snapshot doc — so it can be added later without reshaping the wire. +package proxystatus + +import ( + "bufio" + "fmt" + "net" + "net/http" + "strings" +) + +// Surface identifies which proxy a status page describes. The string value is +// the label in the reserved host (status.) and the human name shown on +// the page. +type Surface string + +// The three proxy surfaces that serve a status page. +const ( + SurfaceDmsg Surface = "dmsg" + SurfaceSkynet Surface = "skynet" + SurfaceSkysocks Surface = "skysocks" +) + +// statusLabel is the reserved first label of every status host. +const statusLabel = "status" + +// Leg is one route in a surface's mux'd route group, decoupled from +// visor.MuxLegInfo so this package doesn't import pkg/visor (which would be a +// cycle — the visor implements Provider). Fields mirror the RouteGroupMuxInfo +// telemetry the `cli proxy mux plot` renderer consumes. +type Leg struct { + Index int + TransportID string + TpType string + RemotePK string + LatencyMS float64 + SentBytes uint64 + RecvBytes uint64 + Retransmits uint64 + Alive bool + Standby bool +} + +// Snapshot is everything a status page renders for one surface. It is a +// point-in-time projection; the page meta-refreshes to re-fetch, so a stale +// Snapshot is self-correcting rather than load-bearing. +// +// Extension seam (route control): a future write-capable status page adds a +// Controls section here (candidate legs, current scheduler mode, selectable dmsg +// relays) plus a matching mutating method on Provider. The read-only fields +// below are already shaped so a control UI can render alongside them without a +// wire change. Keep additions additive. +type Snapshot struct { + Surface Surface + App string // underlying app/logger name (dmsgweb / skynetweb / skysocks-client) + Running bool // whether the surface's process/runtime is currently alive + MuxEnabled bool // route group has multiplexing enabled + Legs []Leg // current per-leg mux state (empty when no active route group) + Logs []string // recent log lines, oldest first + Events []string // route/transport events affecting this surface, oldest first + Note string // optional human note (e.g. why a section is empty) +} + +// Provider yields a live Snapshot for a surface. Implemented by the visor +// (pkg/visor/embedded_proxystatus.go), injected into the resolving-proxy +// runtimes' Config. A nil Provider disables the status hosts entirely. +type Provider interface { + StatusSnapshot(surface Surface) (Snapshot, error) +} + +// Match reports whether host is one of the reserved status hosts and, if so, +// which surface it names. host is the raw request host (an optional :port is +// tolerated and ignored). Matching is case-insensitive and exact on the two +// labels — "status.skysocks" matches but "x.status.skysocks" and "status" alone +// do not, so a status host never shadows a real .. lookup. +func Match(host string) (Surface, bool) { + h := strings.ToLower(strings.TrimSpace(host)) + if i := strings.LastIndexByte(h, ':'); i >= 0 && !strings.Contains(h[i+1:], ".") { + h = h[:i] // strip a trailing :port (never a dotted label) + } + label, rest, ok := strings.Cut(h, ".") + if !ok || label != statusLabel { + return "", false + } + switch Surface(rest) { + case SurfaceDmsg, SurfaceSkynet, SurfaceSkysocks: + return Surface(rest), true + default: + return "", false + } +} + +// Host returns the reserved status host for a surface ("status.skysocks"), for +// links (the interstitial's "view status" affordance) and tests. +func Host(s Surface) string { return statusLabel + "." + string(s) } + +// ServeConn returns one end of an in-memory pipe; the other end is fed body as a +// single HTTP/1.1 response then closed. Nothing is dialed out — the page exists +// only as seen THROUGH the proxy. The returned conn is handed to go-socks5, +// which pipes the browser's request in and the response back. Mirrors dmsgweb's +// serveHomeInProcess so the two reserved in-process hosts behave identically. +func ServeConn(body []byte) net.Conn { + srvConn, cliConn := net.Pipe() + go func() { + defer srvConn.Close() //nolint:errcheck + br := bufio.NewReader(srvConn) + // Consume the request line + headers so the peer's write completes; the + // path is ignored (the status page is the same for every path). + if _, err := http.ReadRequest(br); err != nil { + return + } + var resp strings.Builder + resp.WriteString("HTTP/1.1 200 OK\r\n") + resp.WriteString("Content-Type: text/html; charset=utf-8\r\n") + fmt.Fprintf(&resp, "Content-Length: %d\r\n", len(body)) + resp.WriteString("Cache-Control: no-store\r\n") + resp.WriteString("Connection: close\r\n\r\n") + if _, err := srvConn.Write([]byte(resp.String())); err != nil { + return + } + _, _ = srvConn.Write(body) //nolint:errcheck // best-effort; peer may have gone + }() + return cliConn +} diff --git a/pkg/proxystatus/proxystatus_test.go b/pkg/proxystatus/proxystatus_test.go new file mode 100644 index 0000000000..0e3c60bb0c --- /dev/null +++ b/pkg/proxystatus/proxystatus_test.go @@ -0,0 +1,102 @@ +package proxystatus + +import ( + "bufio" + "net/http" + "strings" + "testing" +) + +func TestMatch(t *testing.T) { + cases := []struct { + host string + want Surface + matched bool + }{ + {"status.dmsg", SurfaceDmsg, true}, + {"status.skynet", SurfaceSkynet, true}, + {"status.skysocks", SurfaceSkysocks, true}, + {"STATUS.SkySocks", SurfaceSkysocks, true}, // case-insensitive + {"status.skysocks:80", SurfaceSkysocks, true}, + {"status.skysocks:8080", SurfaceSkysocks, true}, + {"status", "", false}, // bare label, no surface + {"x.status.skysocks", "", false}, // must be exactly two labels + {"status.unknown", "", false}, // unknown surface + {"pk.skynet", "", false}, // ordinary resolver host + {"home.dmsg", "", false}, // the other reserved host + } + for _, c := range cases { + got, ok := Match(c.host) + if ok != c.matched || got != c.want { + t.Errorf("Match(%q) = (%q,%v), want (%q,%v)", c.host, got, ok, c.want, c.matched) + } + } +} + +func TestHost(t *testing.T) { + if h := Host(SurfaceSkysocks); h != "status.skysocks" { + t.Errorf("Host = %q", h) + } +} + +func TestRenderSections(t *testing.T) { + snap := Snapshot{ + Surface: SurfaceSkysocks, + App: "skysocks-client", + Running: true, + MuxEnabled: true, + Legs: []Leg{ + {Index: 0, TpType: "stcpr", RemotePK: "0311223344556677889900aabbccddeeff00112233445566778899aabbccddeeff", SentBytes: 2048, RecvBytes: 1024, LatencyMS: 42, Alive: true}, + {Index: 1, TpType: "sudph", SentBytes: 512, Standby: true, Alive: true}, + }, + Logs: []string{"line one", "line two"}, + Events: nil, + } + page := string(Render(snap)) + for _, want := range []string{ + "", "proxy status", "skysocks-client", "per-leg mux", + "stcpr", "recent log", "line two", "route control", "read-only preview", + "http-equiv=\"refresh\"", "standby", "status.dmsg", "status.skynet", + } { + if !strings.Contains(page, want) { + t.Errorf("rendered page missing %q", want) + } + } + // The current surface should not be linked back to itself in the footer. + if strings.Contains(page, `href="http://status.skysocks/"`) { + t.Error("footer should not link the current surface to itself") + } +} + +func TestRenderEscapes(t *testing.T) { + snap := Snapshot{Surface: SurfaceDmsg, App: "dmsgweb", Logs: []string{""}} + page := string(Render(snap)) + if strings.Contains(page, "