diff --git a/README.md b/README.md index fed7579..e60efdf 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ questions and changes nothing until you confirm. server has a global IPv6 address. - **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for 400 days by default (Settings → Logs & history). +- **Protection check:** the dashboard shows the address websites see for your + browser next to the server's. The same address means you are behind the + VPN; a peer that only routes the VPN network counts as not protected. - **Live view:** the speed of every peer right now, updated every 2 seconds, with the last 2 minutes as a chart. Kept in memory only. - **Connection history:** every online session per peer, with start, duration, diff --git a/api.go b/api.go index 38f308b..4857a3e 100644 --- a/api.go +++ b/api.go @@ -34,6 +34,7 @@ type App struct { started time.Time shutdown func() // graceful stop; systemd restarts the service webAddrs []string // the addresses the web server listens on now + endpoint endpointIPs } // --- helpers --- @@ -389,6 +390,7 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) { "traffic24h": map[string]int64{"down": d24, "up": u24}, "traffic30d": map[string]int64{"down": d30, "up": u30}, "topPeer30d": top, + "visitor": a.visitor(r, cfg), }) } diff --git a/app.css b/app.css index caf778a..7385ecf 100644 --- a/app.css +++ b/app.css @@ -218,6 +218,22 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d @media (max-width: 1000px) { .hcbody { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 640px) { .hcrow { grid-template-columns: 8px minmax(0, 1fr); } .hcrow .v { grid-column: 2; } } +/* visitor: the address websites see next to the server's */ +.visitor { display: flex; flex-wrap: wrap; align-items: stretch; gap: 16px 24px; } +.vstate { flex: 1 1 340px; min-width: 0; display: flex; gap: 14px; align-items: flex-start; } +.vstate h2 { margin: 2px 0 4px; font-size: 18px; font-weight: 600; } +.vstate p { margin: 0; color: var(--ink-2); font-size: 13px; max-width: 62ch; } +.vicon { flex: none; width: 40px; height: 40px; border-radius: 50%; display: grid; place-items: center; } +.visitor.ok .vicon { background: #e6f5e6; color: #0b7a0b; } +.visitor.ok h2 { color: #0b6b0b; } +.visitor.off .vicon { background: var(--warn-bg); color: var(--warn-ink); } +.visitor.off h2 { color: var(--warn-ink); } +.vaddrs { flex: 2 1 420px; min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } +.visitor .hcaddr { padding: 12px 16px; } +.visitor .hcaddr .v.mono { font-size: 20px; } +.visitor.ok .hcaddr { background: #eef7ee; box-shadow: inset 0 0 0 1px #c5e3c5; } +.visitor.off .hcaddr:first-child { background: var(--warn-bg); box-shadow: inset 0 0 0 1px #f0d2ad; } + /* updates */ .upvers { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; } .upbox { background: var(--ground); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; min-width: 0; } diff --git a/app.js b/app.js index c14a4c7..181b2d0 100644 --- a/app.js +++ b/app.js @@ -51,6 +51,8 @@ plus: '', key: '', log: '', + shield: '', + shieldoff: '', logout: '', }; @@ -1033,6 +1035,37 @@ // RANGES are the time ranges offered above the traffic charts. const RANGES = [['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']]; + // visitorCard shows the address websites see for this browser next to the + // server's: the same address means the browser is behind the VPN. + function visitorCard(v) { + if (!v) return null; + const addr = (label, value, note) => h('div', { class: 'hcaddr' }, + h('span', { class: 'l' }, label), h('span', { class: 'v mono' }, value || 'Unknown'), h('span', { class: 'n' }, note)); + const server = addr('Server IP address', v.serverIP, v.protected ? 'Same address: you are behind the VPN' : 'Different address: you are not behind the VPN'); + if (v.protected) { + return h('section', { class: 'card visitor ok', 'aria-labelledby': 'vis' }, + h('div', { class: 'vstate' }, + h('span', { class: 'vicon' }, icon('shield', 22, 1.8)), + h('div', null, + h('h2', { id: 'vis' }, 'You are protected'), + h('p', null, v.peer + ? ['This browser is connected through the tunnel as ', peerLink(v.peer), '. All its traffic goes out through this server, so websites see the server\'s address, not yours.'] + : 'This browser\'s traffic goes out through this server, so websites see the server\'s address, not yours.'))), + h('div', { class: 'vaddrs' }, addr('Your IP address', v.ip, 'What websites see'), server)); + } + const where = v.peer ? 'Tunnel address of ' + v.peer.name + : v.location ? [v.location.countryName, v.location.network].filter(Boolean).join(' · ') : 'What websites see'; + return h('section', { class: 'card visitor off', 'aria-labelledby': 'vis' }, + h('div', { class: 'vstate' }, + h('span', { class: 'vicon' }, icon('shieldoff', 22, 1.8)), + h('div', null, + h('h2', { id: 'vis' }, 'Not protected'), + h('p', null, v.peer + ? ['This browser uses the tunnel as ', peerLink(v.peer), ' only for the VPN network. Its other traffic skips the VPN, so websites see your own address.'] + : 'This browser connects directly, not through the VPN. Websites see your own address. Turn on the tunnel on this device to browse through this server.'))), + h('div', { class: 'vaddrs' }, addr('Your IP address', v.ip, where), server)); + } + async function viewDashboard(wrap) { let range = '24h'; const draw = async () => { @@ -1065,6 +1098,8 @@ h('div', null, h('h1', null, 'Dashboard'), h('p', { class: 'sub' }, 'Endpoint ', h('span', { class: 'mono' }, st.endpoint), ' · network ', h('span', { class: 'mono' }, st.ipv4)))), + visitorCard(st.visitor), + failing.length ? h('div', { class: 'notice err', role: 'alert' }, h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')), h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null, diff --git a/main_test.go b/main_test.go index e58e790..03656bd 100644 --- a/main_test.go +++ b/main_test.go @@ -1607,3 +1607,42 @@ func TestApplyOrder(t *testing.T) { t.Fatalf("the kernel ended with %q, not the newest config; applies: %q", last, k.applied) } } + +func TestClassifyVisitor(t *testing.T) { + c := testConfig(t) + c.Peers = []Peer{ + {ID: "full", Name: "phone", IPv4: "10.84.12.2", Enabled: true}, + {ID: "split", Name: "laptop", IPv4: "10.84.12.3", Enabled: true, AllowedIPs: []string{"10.84.12.0/24"}}, + {ID: "off", Name: "old", IPv4: "10.84.12.4"}, + } + server := netip.MustParseAddr("203.0.113.10") + none := func(netip.Addr) bool { return false } + lan := func(ip netip.Addr) bool { return ip == netip.MustParseAddr("192.168.1.5") } + for _, tc := range []struct { + ip string + own func(netip.Addr) bool + protected bool + shown string + peer string + }{ + {"10.84.12.2", none, true, "203.0.113.10", "phone"}, // full tunnel + {"10.84.12.3", none, false, "10.84.12.3", "laptop"}, // split tunnel + {"10.84.12.4", none, false, "10.84.12.4", ""}, // disabled peer + {"203.0.113.10", none, true, "203.0.113.10", ""}, // looped back through the router + {"192.168.1.5", lan, true, "203.0.113.10", ""}, // the server's own address + {"198.51.100.77", none, false, "198.51.100.77", ""}, // directly + {"127.0.0.1", func(netip.Addr) bool { return true }, false, "127.0.0.1", ""}, // local proxy + } { + v := classifyVisitor(c, netip.MustParseAddr(tc.ip), server, tc.own) + name := "" + if v.Peer != nil { + name = v.Peer.Name + } + if v.Protected != tc.protected || v.IP != tc.shown || name != tc.peer || v.ServerIP != "203.0.113.10" { + t.Errorf("%s: got %+v", tc.ip, v) + } + } + if !fullTunnel([]string{"0.0.0.0/1", "128.0.0.0/1"}, false) || fullTunnel([]string{"::/0"}, false) || !fullTunnel([]string{"::/0"}, true) { + t.Error("fullTunnel") + } +} diff --git a/visitor.go b/visitor.go new file mode 100644 index 0000000..de16e4a --- /dev/null +++ b/visitor.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "net" + "net/http" + "net/netip" + "sync" + "time" +) + +// visitorView tells the dashboard whether the browser asking is behind the +// VPN. Behind it, websites see the server's address, so the card shows the +// visitor's address next to the server's: the same means protected. +type visitorView struct { + Protected bool `json:"protected"` + IP string `json:"ip"` // what websites see; the server's address when protected + ServerIP string `json:"serverIP"` // empty when the endpoint does not resolve + Peer *peerRef `json:"peer"` // the peer whose tunnel the request came through + Location *GeoInfo `json:"location"` // of IP, when not protected +} + +type peerRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// classifyVisitor decides on the address the server sees (remoteIP). +// Through the tunnel that is the peer's tunnel address; the peer is +// protected when its AllowedIPs send all traffic of that family through +// the server. A server behind NAT may instead see its own or its public +// address, when the request loops back through the router. +func classifyVisitor(c *Config, ip, server netip.Addr, own func(netip.Addr) bool) visitorView { + v := visitorView{IP: ip.String()} + if server.IsValid() { + v.ServerIP = server.String() + } + if p := peerByTunnelAddr(c, ip); p != nil { + v.Peer = &peerRef{ID: p.ID, Name: p.Name} + v.Protected = fullTunnel(peerAllowedIPs(c, p), ip.Is6()) + } else if !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && (ip == server || own(ip)) { + v.Protected = true + } + if v.Protected && server.IsValid() { + v.IP = v.ServerIP + } + return v +} + +func peerByTunnelAddr(c *Config, ip netip.Addr) *Peer { + for i := range c.Peers { + p := &c.Peers[i] + if !p.Enabled { + continue + } + for _, a := range peerAddresses(c, p) { + if a.Addr() == ip { + return p + } + } + } + return nil +} + +// fullTunnel reports whether AllowedIPs route all IPv4 (or IPv6) traffic +// into the tunnel: 0.0.0.0/0, ::/0, or the two halves some clients use. +func fullTunnel(allowed []string, v6 bool) bool { + halves := 0 + for _, s := range allowed { + p, err := netip.ParsePrefix(s) + if err != nil || p.Addr().Is6() != v6 { + continue + } + switch p.Bits() { + case 0: + return true + case 1: + halves++ + } + } + return halves >= 2 +} + +// ownAddr reports whether ip is an address of this machine. +func ownAddr(ip netip.Addr) bool { + addrs, err := net.InterfaceAddrs() + if err != nil { + return false + } + for _, a := range addrs { + if n, ok := a.(*net.IPNet); ok { + if x, ok := netip.AddrFromSlice(n.IP); ok && x.Unmap() == ip { + return true + } + } + } + return false +} + +// endpointIPs resolves the WireGuard endpoint, the address websites see +// behind the VPN, and keeps the answer for a few minutes. +type endpointIPs struct { + mu sync.Mutex + host string + addrs []netip.Addr + at time.Time +} + +func (e *endpointIPs) lookup(host string, v6 bool) netip.Addr { + if ip, err := netip.ParseAddr(host); err == nil { + return ip.Unmap() + } + e.mu.Lock() + defer e.mu.Unlock() + if host != e.host || time.Since(e.at) > 10*time.Minute { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + addrs, _ := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + cancel() + e.host, e.addrs, e.at = host, addrs, time.Now() + } + var other netip.Addr + for _, a := range e.addrs { + a = a.Unmap() + if a.Is6() == v6 { + return a + } + if !other.IsValid() { + other = a + } + } + return other +} + +func (a *App) visitor(r *http.Request, c *Config) visitorView { + ip, err := netip.ParseAddr(remoteIP(r)) + if err != nil { + return visitorView{IP: remoteIP(r)} + } + ip = ip.Unmap() + v := classifyVisitor(c, ip, a.endpoint.lookup(c.Server.Endpoint, ip.Is6()), ownAddr) + if !v.Protected && v.Peer == nil { + v.Location = a.geo.Lookup(v.IP) + } + return v +}