Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/proxy-status-and-interstitial.md
Original file line number Diff line number Diff line change
@@ -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.<suffix>` 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.
32 changes: 30 additions & 2 deletions pkg/dmsgweb/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down
26 changes: 26 additions & 0 deletions pkg/proxyinterstitial/interstitial.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := `<li>Fetching the page over ` + html.EscapeString(mech) + `</li>`

// 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 := `<div class="foot">routed privately over the skywire mesh`
if h := statusHost(mech); h != "" {
footer += ` &middot; <a href="http://` + html.EscapeString(h) + `/">proxy status ›</a>`
}
footer += `</div>`
return `<!doctype html><html lang="en"><head><meta charset="utf-8">` +
`<meta name="viewport" content="width=device-width, initial-scale=1">` +
refreshMeta +
Expand All @@ -115,9 +125,23 @@ func Page(target, detail, mechanism string, isError bool) string {
`</ul>` +
retry +
hostBlock +
footer +
`</div></div></body></html>`
}

// 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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions pkg/proxyinterstitial/interstitial_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
146 changes: 146 additions & 0 deletions pkg/proxystatus/provider.go
Original file line number Diff line number Diff line change
@@ -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.<surface>) 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 <vhost>.<pk>.<suffix> 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
}
Loading
Loading