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
28 changes: 28 additions & 0 deletions docs/proxy-status-and-interstitial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`*.<suffix>`) 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-<surface>.<suffix>` 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"
Expand Down
68 changes: 68 additions & 0 deletions pkg/proxystatus/proxystatus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import (
"bufio"
"math"
"net/http"
"strconv"
"strings"
"testing"
)
Expand Down Expand Up @@ -100,3 +102,69 @@
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)

Check failure on line 111 in pkg/proxystatus/proxystatus_test.go

View workflow job for this annotation

GitHub Actions / linux

Error return value of `strconv.ParseInt` is not checked (errcheck)

Check failure on line 111 in pkg/proxystatus/proxystatus_test.go

View workflow job for this annotation

GitHub Actions / windows

Error return value of `strconv.ParseInt` is not checked (errcheck)

Check failure on line 111 in pkg/proxystatus/proxystatus_test.go

View workflow job for this annotation

GitHub Actions / darwin

Error return value of `strconv.ParseInt` is not checked (errcheck)
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)
}
}
}
14 changes: 11 additions & 3 deletions pkg/proxystatus/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
`<th>leg</th><th>transport</th><th>peer</th><th>sent</th><th>bandwidth (sent share)</th>` +
`<th>recv</th><th>rtt</th><th>rtx</th><th>state</th></tr></thead><tbody>`)
for _, l := range snap.Legs {
pct := int(l.SentBytes * 100 / maxSent)

Check failure on line 96 in pkg/proxystatus/render.go

View workflow job for this annotation

GitHub Actions / linux

G115: integer overflow conversion uint64 -> int (gosec)

Check failure on line 96 in pkg/proxystatus/render.go

View workflow job for this annotation

GitHub Actions / windows

G115: integer overflow conversion uint64 -> int (gosec)

Check failure on line 96 in pkg/proxystatus/render.go

View workflow job for this annotation

GitHub Actions / darwin

G115: integer overflow conversion uint64 -> int (gosec)
state, scls := legState(l)
fmt.Fprintf(b, `<tr><td>R%d</td><td>%s</td><td class="pk">%s</td><td>%s</td>`,
l.Index, html.EscapeString(orDash(l.TpType)), html.EscapeString(shortPK(l.RemotePK)), humanBytes(l.SentBytes))
Expand Down Expand Up @@ -223,7 +223,14 @@
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}` +
Expand All @@ -245,9 +252,10 @@
`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}}`
68 changes: 66 additions & 2 deletions pkg/visor/meshproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"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"
Expand Down Expand Up @@ -641,15 +642,73 @@
// serveMeshSubdomain runs the single Host-routed reverse-proxy origin. The browse
// iframe targets <scheme>://<vhost>.<base32pk><suffix>[: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-<surface><suffix>) 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-<surface>.<suffix> (e.g.
// status-skysocks.haltingstate.net) — a SINGLE label under the suffix, so the
// deployment's real single-level wildcard cert (*.<suffix>, 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

Check failure on line 677 in pkg/visor/meshproxy.go

View workflow job for this annotation

GitHub Actions / linux

G705: XSS via taint analysis (gosec)

Check failure on line 677 in pkg/visor/meshproxy.go

View workflow job for this annotation

GitHub Actions / windows

G705: XSS via taint analysis (gosec)

Check failure on line 677 in pkg/visor/meshproxy.go

View workflow job for this annotation

GitHub Actions / darwin

G705: XSS via taint analysis (gosec)
return
}
}
next.ServeHTTP(w, r)
})
}

// meshStatusSurface reports whether host is a browse-origin status host
// (status-<surface><suffix>) and which surface it names. The single
// "status-<surface>" 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 <vhost>.<pk><suffix> 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 {
Expand Down Expand Up @@ -817,5 +876,10 @@
// manager's own scheme://127.0.0.1:<port> 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)
}
40 changes: 40 additions & 0 deletions pkg/visor/meshproxy_status_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading