diff --git a/docs/proxy-status-and-interstitial.md b/docs/proxy-status-and-interstitial.md index dca5100c6c..6b9513cd2c 100644 --- a/docs/proxy-status-and-interstitial.md +++ b/docs/proxy-status-and-interstitial.md @@ -71,6 +71,34 @@ MVP status vs. scaffold: - **route/transport events** render an empty section today — the collection buffer is the scaffolded extension point. +### Reaching status over HTTPS (real cert, no warning) + +The in-process SOCKS path above serves the status pages over **plain HTTP** at +bare hosts (`status.skysocks` etc.) — fine through the resolving proxy, but a +browser hitting `https://status.skysocks/` would need the self-signed skynetca CA +installed to avoid a warning. + +For a warning-free `https://`, the status pages are **also** served through the +browse-origin listener (`pkg/visor/meshproxy.go`, gated by `BrowseOrigin.Enable`), +which already terminates TLS with the deployment's **real** wildcard cert +(`BrowseOrigin.TLSCert`/`TLSKey`, or a fronting Caddy) under `BrowseOrigin.Suffix` +(e.g. `.haltingstate.net`). Reached at a **single-label** host so a single-level +wildcard (`*.`) covers it: + + https://status-skysocks.haltingstate.net/ + https://status-dmsg.haltingstate.net/ + https://status-skynet.haltingstate.net/ + +`meshStatusHandler` intercepts these hosts on the browse-origin mux (both +`subdomain` and `port` modes) **before** the reverse proxy, renders the same +`proxystatus` page, and lets every other (browse-frame) host fall through. This is +the real-cert alternative to the name-constrained skynetca leaf path in +`pkg/skynetweb` — no CA install. When browse-origin is disabled or unconfigured in +a deployment, the plain-HTTP SOCKS path remains the fallback. + +Note the wildcard is single-level: `status-.` is covered, but a +multi-label host is not — which is also why the matcher rejects multi-label hosts. + ### Extension seam: route control The MVP is deliberately read-only. The page renders a disabled "route control" diff --git a/pkg/proxystatus/proxystatus_test.go b/pkg/proxystatus/proxystatus_test.go index 0e3c60bb0c..45d8890342 100644 --- a/pkg/proxystatus/proxystatus_test.go +++ b/pkg/proxystatus/proxystatus_test.go @@ -2,7 +2,9 @@ package proxystatus import ( "bufio" + "math" "net/http" + "strconv" "strings" "testing" ) @@ -100,3 +102,69 @@ func TestServeConn(t *testing.T) { t.Errorf("content-type = %q", ct) } } + +// --- WCAG contrast (the dark + light legibility fix) --------------------------- + +func hexLum(h string) float64 { + h = strings.TrimPrefix(h, "#") + c := func(i int) float64 { + v, _ := strconv.ParseInt(h[i:i+2], 16, 0) + s := float64(v) / 255 + if s <= 0.03928 { + return s / 12.92 + } + return math.Pow((s+0.055)/1.055, 2.4) + } + r, g, b := c(0), c(2), c(4) + return 0.2126*r + 0.7152*g + 0.0722*b +} + +func contrast(fg, bg string) float64 { + l1, l2 := hexLum(fg)+0.05, hexLum(bg)+0.05 + if l1 < l2 { + l1, l2 = l2, l1 + } + return l1 / l2 +} + +// TestContrastAA locks the legibility fix: every text token the status page uses +// must clear WCAG AA body-text contrast (>=4.5:1) against the surface it sits on, +// in BOTH the dark default and the light prefers-color-scheme block. +func TestContrastAA(t *testing.T) { + const ( + darkBG, darkCard, darkMuted, darkFG = "#0b0d17", "#131629", "#a2a8cc", "#c7cbe6" + lightBG, lightMuted, lightFG = "#f6f7fb", "#4a4f63", "#1c1e26" + lightOK, lightWarn, lightStandby = "#0a7a4c", "#c02a48", "#7a5c00" + ) + pairs := []struct { + name string + fg, bg string + }{ + {"dark muted on bg", darkMuted, darkBG}, + {"dark muted on card", darkMuted, darkCard}, + {"dark fg on bg", darkFG, darkBG}, + {"light muted on bg", lightMuted, lightBG}, + {"light fg on bg", lightFG, lightBG}, + {"light ok on bg", lightOK, lightBG}, + {"light warn on bg", lightWarn, lightBG}, + {"light standby on bg", lightStandby, lightBG}, + } + for _, p := range pairs { + if r := contrast(p.fg, p.bg); r < 4.5 { + t.Errorf("%s: contrast %.2f:1 < 4.5:1", p.name, r) + } + } + // The token values asserted above must actually be the ones the stylesheet + // ships, or the test guards nothing. + for _, want := range []string{darkMuted, lightMuted, lightOK, lightWarn, lightStandby} { + if !strings.Contains(css, want) { + t.Errorf("css missing asserted token %s", want) + } + } + // The old low-contrast greys must be gone. + for _, gone := range []string{"#7a80a8", "--muted:#666"} { + if strings.Contains(css, gone) { + t.Errorf("css still contains low-contrast token %q", gone) + } + } +} diff --git a/pkg/proxystatus/render.go b/pkg/proxystatus/render.go index 1a8f7beddd..aadf5dc465 100644 --- a/pkg/proxystatus/render.go +++ b/pkg/proxystatus/render.go @@ -223,7 +223,14 @@ func humanBytes(n uint64) string { return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) } -const css = `:root{--bg:#0b0d17;--fg:#c7cbe6;--muted:#7a80a8;--accent:#7c83ff;--accent2:#a06bff;--ok:#4ad9a4;--warn:#ff6b8a;--standby:#e0b64a;--card:#131629;--line:#2b3163}` + +// css: the dark default is tuned so every text token clears WCAG AA (≥4.5:1 for +// body text) against --bg/--card — notably --muted (#a2a8cc ≈ 8:1 on --bg), +// which every low-emphasis label (pills, table headers, .hint/.empty, footer, +// log tail) uses. The light block re-darkens --muted AND the status colors +// (--ok/--warn/--standby), whose dark-mode brights are illegible on a light +// background, so the same ≥4.5:1 floor holds in both schemes. The accent +// gradient and overall identity are unchanged. +const css = `:root{--bg:#0b0d17;--fg:#c7cbe6;--muted:#a2a8cc;--accent:#7c83ff;--accent2:#a06bff;--ok:#4ad9a4;--warn:#ff6b8a;--standby:#e0b64a;--card:#131629;--line:#2b3163}` + `*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:13.5px/1.55 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}` + `body{max-width:60rem;margin:0 auto;padding:1.2rem 1rem 3rem}` + `header{display:flex;align-items:baseline;gap:.8rem;flex-wrap:wrap;border-bottom:1px solid var(--line);padding-bottom:.7rem}` + @@ -245,9 +252,10 @@ const css = `:root{--bg:#0b0d17;--fg:#c7cbe6;--muted:#7a80a8;--accent:#7c83ff;-- `pre.log{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:.7rem;font:11.5px/1.5 ui-monospace,SFMono-Regular,monospace;` + `white-space:pre-wrap;word-break:break-word;max-height:26rem;overflow:auto;color:var(--fg)}` + `ul.events{margin:.3rem 0;padding-left:1.1rem;font-size:12px}ul.events li{margin:.1rem 0}` + - `.seam{opacity:.75}.controls{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.4rem}` + + `.seam{opacity:.9}.controls{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.4rem}` + `.controls button{font:inherit;font-size:12px;padding:.2rem .6rem;border:1px dashed var(--line);border-radius:6px;background:transparent;color:var(--muted);cursor:not-allowed}` + `code{color:var(--accent);font-size:11.5px}` + `footer{margin-top:2rem;padding-top:.7rem;border-top:1px solid var(--line);color:var(--muted);font-size:12px}` + `footer a{color:var(--accent);text-decoration:none}footer a:hover{text-decoration:underline}` + - `@media(prefers-color-scheme:light){:root{--bg:#f6f7fb;--fg:#222;--muted:#666;--card:#fff;--line:#e2e4ef;--accent:#5a61e6;--accent2:#8a4fe0}}` + `@media(prefers-color-scheme:light){:root{--bg:#f6f7fb;--fg:#1c1e26;--muted:#4a4f63;--card:#fff;--line:#d3d6e4;--accent:#4149d6;--accent2:#7b3fd0;--ok:#0a7a4c;--warn:#c02a48;--standby:#7a5c00}` + + `h2,.surface{color:#1c1e26}}` diff --git a/pkg/visor/meshproxy.go b/pkg/visor/meshproxy.go index 9cceb5168a..c862a93a2b 100644 --- a/pkg/visor/meshproxy.go +++ b/pkg/visor/meshproxy.go @@ -45,6 +45,7 @@ import ( "github.com/skycoin/skywire/pkg/cipher" "github.com/skycoin/skywire/pkg/logging" "github.com/skycoin/skywire/pkg/proxyinterstitial" + "github.com/skycoin/skywire/pkg/proxystatus" "github.com/skycoin/skywire/pkg/routing" "github.com/skycoin/skywire/pkg/skyenv" "github.com/skycoin/skywire/pkg/skynetweb" @@ -641,15 +642,73 @@ func (v *Visor) ServeMeshProxy(ctx context.Context, cfg *visorconfig.BrowseOrigi // serveMeshSubdomain runs the single Host-routed reverse-proxy origin. The browse // iframe targets ://.[:port]/ against addr. func (v *Visor) serveMeshSubdomain(ctx context.Context, addr string, cfg *visorconfig.BrowseOriginConfig, aliases map[string]cipher.PubKey) error { - rp, err := v.newMeshReverseProxy(subdomainResolver(normalizeMeshSuffix(cfg.Suffix), aliases)) + suffix := normalizeMeshSuffix(cfg.Suffix) + rp, err := v.newMeshReverseProxy(subdomainResolver(suffix, aliases)) if err != nil { return err } mux := http.NewServeMux() - mux.Handle("/", rp) + // Reserved proxy-status hosts (status-) served over THIS + // listener so the browse-origin's real (wildcard) TLS cert covers them — + // warning-free HTTPS status pages. Any non-status host falls through to the + // reverse proxy. See meshStatusHandler. + mux.Handle("/", meshStatusHandler(suffix, v.proxyStatusProvider(), rp)) return serveMeshHTTP(ctx, addr, mux, cfg.TLSCert, cfg.TLSKey) } +// meshStatusHandler serves the reserved proxy-status pages over the browse-origin +// listener. They are reached at status-. (e.g. +// status-skysocks.haltingstate.net) — a SINGLE label under the suffix, so the +// deployment's real single-level wildcard cert (*., terminated here or by +// a fronting Caddy) covers them and the page loads over https:// with no browser +// warning. This is the real-cert alternative to the self-signed skynetca leaf +// path (pkg/skynetweb): no CA install needed. Any non-status host falls through +// to next (the reverse proxy). A nil provider disables status here entirely. +func meshStatusHandler(suffix string, status proxystatus.Provider, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if status != nil { + if surface, ok := meshStatusSurface(hostWithoutPort(r.Host), suffix); ok { + snap, serr := status.StatusSnapshot(surface) + if serr != nil { + snap = proxystatus.Snapshot{Surface: surface, Note: "status unavailable: " + serr.Error()} + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(proxystatus.Render(snap)) //nolint:errcheck + return + } + } + next.ServeHTTP(w, r) + }) +} + +// meshStatusSurface reports whether host is a browse-origin status host +// (status-) and which surface it names. The single +// "status-" label keeps it under a single-level wildcard cert. Matching +// is case-insensitive and exact — a multi-label host (a real browse frame) never +// matches, so status never shadows a . lookup. +func meshStatusSurface(host, suffix string) (proxystatus.Surface, bool) { + h := strings.ToLower(strings.TrimSpace(host)) + sfx := strings.ToLower(suffix) + if !strings.HasSuffix(h, sfx) { + return "", false + } + label := strings.TrimSuffix(h, sfx) + if !strings.HasPrefix(label, "status-") { + return "", false + } + rest := strings.TrimPrefix(label, "status-") + if strings.Contains(rest, ".") { + return "", false + } + switch proxystatus.Surface(rest) { + case proxystatus.SurfaceDmsg, proxystatus.SurfaceSkynet, proxystatus.SurfaceSkysocks: + return proxystatus.Surface(rest), true + default: + return "", false + } +} + // --- port mode --------------------------------------------------------------- type meshPortEntry struct { @@ -817,5 +876,10 @@ func (v *Visor) serveMeshPortMode(ctx context.Context, addr string, cfg *visorco // manager's own scheme://127.0.0.1: origin from the local pool. http.Redirect(w, r, u, http.StatusFound) //nolint:gosec // G710: redirect target is a local loopback origin we minted, not tainted input }) + // Reserved proxy-status hosts on the portal listener too (served under the + // same suffix + real cert). "/open" is the more specific pattern, so this + // root handler only sees other paths; a status host renders, everything else + // 404s (the portal has no content of its own). + mux.Handle("/", meshStatusHandler(normalizeMeshSuffix(cfg.Suffix), v.proxyStatusProvider(), http.NotFoundHandler())) return serveMeshHTTP(ctx, addr, mux, cfg.TLSCert, cfg.TLSKey) } diff --git a/pkg/visor/meshproxy_status_test.go b/pkg/visor/meshproxy_status_test.go new file mode 100644 index 0000000000..e757b0a912 --- /dev/null +++ b/pkg/visor/meshproxy_status_test.go @@ -0,0 +1,40 @@ +package visor + +import ( + "testing" + + "github.com/skycoin/skywire/pkg/proxystatus" +) + +func TestMeshStatusSurface(t *testing.T) { + const suffix = ".haltingstate.net" + cases := []struct { + host string + want proxystatus.Surface + matched bool + }{ + {"status-skysocks.haltingstate.net", proxystatus.SurfaceSkysocks, true}, + {"status-dmsg.haltingstate.net", proxystatus.SurfaceDmsg, true}, + {"status-skynet.haltingstate.net", proxystatus.SurfaceSkynet, true}, + {"STATUS-SkySocks.haltingstate.net", proxystatus.SurfaceSkysocks, true}, + {"status-skysocks.haltingstate.net:8443", "", false}, // caller strips port; a raw :port here is not matched + {"status-unknown.haltingstate.net", "", false}, + {"vhost.abcd.haltingstate.net", "", false}, // ordinary browse frame + {"status-skysocks.x.haltingstate.net", "", false}, // multi-label, not wildcard-safe + {"status-skysocks.example.com", "", false}, // wrong suffix + {"status-skysocks", "", false}, // no suffix + } + for _, c := range cases { + got, ok := meshStatusSurface(c.host, suffix) + if ok != c.matched || got != c.want { + t.Errorf("meshStatusSurface(%q) = (%q,%v), want (%q,%v)", c.host, got, ok, c.want, c.matched) + } + } +} + +func TestMeshStatusSurfaceLocalhostSuffix(t *testing.T) { + // The default (local) suffix also matches, so status works over http locally. + if s, ok := meshStatusSurface("status-skysocks.mesh.localhost", ".mesh.localhost"); !ok || s != proxystatus.SurfaceSkysocks { + t.Errorf("localhost suffix: got (%q,%v)", s, ok) + } +}