Phase 4: authorised HTTPS interception (Mode B)

Toggleable transparent interception via mitmdump in a venv. Own RSA CA generated
in-app, downloadable (public cert only, key never leaves the box), with per-OS
install guide and fingerprint. nftables redirect accepts only dnat'd connections
(never an open proxy). HTTP Explorer with headers and opt-in body viewers (JSON/
text/hex/image, sandboxed). Honest status: pinned/untrusting = reported errors,
excluded hosts pass through, bodies off by default. Helper op + systemd unit +
installer venv build + boot restore.
This commit is contained in:
Andy Dixon
2026-10-10 08:05:42 +01:00
parent 4320428f41
commit 5dbf85f84e
25 changed files with 1460 additions and 13 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ build: web
$(GO) build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(PKG)/bin/pilens-helper ./cmd/pilens-helper $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(PKG)/bin/pilens-helper ./cmd/pilens-helper
package: build package: build
cp -r systemd packaging/setup.example.yaml dist/$(PKG)/ cp -r systemd packaging/setup.example.yaml packaging/mitm_addon.py dist/$(PKG)/
cp scripts/*.sh dist/$(PKG)/ cp scripts/*.sh dist/$(PKG)/
cp -r docs README.md LICENSE dist/$(PKG)/ cp -r docs README.md LICENSE dist/$(PKG)/
echo $(VERSION) > dist/$(PKG)/VERSION echo $(VERSION) > dist/$(PKG)/VERSION
+104 -2
View File
@@ -142,6 +142,12 @@ func (s *server) handle(ctx context.Context, c *net.UnixConn) {
data = s.status(ctx) data = s.status(ctx)
case helper.OpStations: case helper.OpStations:
data, err = stations(ctx) data, err = stations(ctx)
case helper.OpSetInterception:
r := s.setInterception(ctx, req.InterceptEnabled, req.InterceptPort)
data = r
if r.Error != "" {
err = errors.New(r.Error)
}
case helper.OpLeases: case helper.OpLeases:
b, rerr := os.ReadFile(netgen.LeaseFile) b, rerr := os.ReadFile(netgen.LeaseFile)
if rerr != nil && !os.IsNotExist(rerr) { if rerr != nil && !os.IsNotExist(rerr) {
@@ -232,7 +238,7 @@ func (s *server) apply(ctx context.Context, n config.Network) helper.ApplyResult
return r return r
} }
step("validated configuration") step("validated configuration")
ruleset := netgen.Nftables(n) ruleset := netgen.Nftables(n, loadIntercept())
tmp := statePath("ruleset.check.nft") tmp := statePath("ruleset.check.nft")
if err := writeAtomic(tmp, []byte(ruleset), 0o600); err != nil { if err := writeAtomic(tmp, []byte(ruleset), 0o600); err != nil {
r.Error = err.Error() r.Error = err.Error()
@@ -441,6 +447,98 @@ func stations(ctx context.Context) ([]helper.Station, error) {
return helper.ParseStationDump(out), nil return helper.ParseStationDump(out), nil
} }
// --- interception state (persisted so apply_network and boot keep the redirect) ---
type interceptState struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
}
func interceptPath() string { return statePath("intercept.json") }
func loadIntercept() *netgen.Intercept {
b, err := os.ReadFile(interceptPath())
if err != nil {
return nil
}
var st interceptState
if json.Unmarshal(b, &st) != nil || !st.Enabled {
return nil
}
return &netgen.Intercept{Enabled: true, Port: st.Port}
}
// setInterception toggles transparent interception: re-renders the firewall with
// (or without) the redirect, then starts/stops the proxy service. The CA and the
// proxy's configuration are owned by the core; the helper only wires the network
// and the service.
func (s *server) setInterception(ctx context.Context, enabled bool, port int) helper.ApplyResult {
s.mu.Lock()
defer s.mu.Unlock()
var r helper.ApplyResult
step := func(f string, a ...any) { r.Steps = append(r.Steps, fmt.Sprintf(f, a...)) }
if port <= 0 || port > 65535 {
r.Error = "invalid proxy port"
return r
}
b, err := os.ReadFile(statePath("last-good.json"))
if err != nil {
r.Error = "apply a network configuration before enabling interception"
return r
}
var n config.Network
if err := json.Unmarshal(b, &n); err != nil {
r.Error = "stored network configuration unreadable"
return r
}
ic := (*netgen.Intercept)(nil)
if enabled {
ic = &netgen.Intercept{Enabled: true, Port: port}
}
ruleset := netgen.Nftables(n, ic)
tmp := statePath("ruleset.check.nft")
writeAtomic(tmp, []byte(ruleset), 0o600)
if _, err := run(ctx, 20*time.Second, "nft", "-c", "-f", tmp); err != nil {
r.Error = "firewall ruleset rejected: " + err.Error()
return r
}
// Persist state first so a concurrent apply_network renders consistently.
stB, _ := json.Marshal(interceptState{Enabled: enabled, Port: port})
writeAtomic(interceptPath(), stB, 0o600)
writeAtomic(statePath("ruleset.nft"), []byte(ruleset), 0o600)
if _, err := run(ctx, 20*time.Second, "nft", "-f", statePath("ruleset.nft")); err != nil {
r.Error = "loading firewall failed: " + err.Error()
return r
}
step("firewall updated (%s)", map[bool]string{true: "redirecting HTTP/HTTPS to the proxy", false: "redirect removed"}[enabled])
if enabled {
if _, err := run(ctx, 30*time.Second, "systemctl", "restart", "pilens-proxy.service"); err != nil {
writeAtomic(interceptPath(), mustJSON(interceptState{Enabled: false, Port: port}), 0o600)
writeAtomic(statePath("ruleset.nft"), []byte(netgen.Nftables(n, nil)), 0o600)
run(ctx, 20*time.Second, "nft", "-f", statePath("ruleset.nft"))
r.Error = "proxy failed to start, interception reverted: " + err.Error()
r.RolledBack = true
return r
}
deadline := time.Now().Add(15 * time.Second)
for !unitActive(ctx, "pilens-proxy.service") {
if time.Now().After(deadline) {
r.Error = "proxy did not become active"
return r
}
time.Sleep(time.Second)
}
step("interception proxy active on port %d", port)
} else {
run(ctx, 20*time.Second, "systemctl", "stop", "pilens-proxy.service")
step("interception proxy stopped")
}
r.Applied = true
return r
}
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
// bootLoad restores the last good ruleset (or a fail-closed one) before networking starts. // bootLoad restores the last good ruleset (or a fail-closed one) before networking starts.
func bootLoad() error { func bootLoad() error {
ctx := context.Background() ctx := context.Background()
@@ -462,6 +560,10 @@ func bootLoad() error {
run(ctx, 10*time.Second, "iw", "reg", "set", n.AP.Country) run(ctx, 10*time.Second, "iw", "reg", "set", n.AP.Country)
} }
} }
if ic := loadIntercept(); ic != nil {
run(ctx, 30*time.Second, "systemctl", "start", "pilens-proxy.service")
slog.Info("interception proxy started at boot")
}
slog.Info("firewall restored at boot") slog.Info("firewall restored at boot")
return nil return nil
} }
@@ -481,6 +583,6 @@ func renderCmd(path string) int {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
return 1 return 1
} }
fmt.Printf("### nftables\n%s\n### dnsmasq.conf\n%s\n### %s (psk redacted)\n%s", netgen.Nftables(n), netgen.DnsmasqConf(n), keyfilePath, netgen.NMKeyfile(n.Redacted())) fmt.Printf("### nftables\n%s\n### dnsmasq.conf\n%s\n### %s (psk redacted)\n%s", netgen.Nftables(n, loadIntercept()), netgen.DnsmasqConf(n), keyfilePath, netgen.NMKeyfile(n.Redacted()))
return 0 return 0
} }
+16 -1
View File
@@ -36,6 +36,7 @@ import (
"pilens.dev/pilens/internal/config" "pilens.dev/pilens/internal/config"
"pilens.dev/pilens/internal/helper" "pilens.dev/pilens/internal/helper"
"pilens.dev/pilens/internal/pcapfile" "pilens.dev/pilens/internal/pcapfile"
"pilens.dev/pilens/internal/proxy"
"pilens.dev/pilens/internal/store" "pilens.dev/pilens/internal/store"
"pilens.dev/pilens/web" "pilens.dev/pilens/web"
) )
@@ -134,13 +135,20 @@ func serve(args []string) error {
go eng.Reindex(id) go eng.Reindex(id)
} }
host, _ := os.Hostname()
sink := proxySink{eng.Hub}
px := proxy.New(db, filepath.Join(*data, "proxy"), host, sink)
if err := px.EnsureCA(); err != nil {
slog.Warn("interception CA init deferred", "err", err)
}
cert, fp, err := ensureCert(*data) cert, fp, err := ensureCert(*data)
if err != nil { if err != nil {
return err return err
} }
slog.Info("management certificate", "sha256", fp) slog.Info("management certificate", "sha256", fp)
srv := &api.Server{DB: db, Engine: eng, Helper: helper.Client{Socket: *sock}, Version: Version, Web: web.FS(), srv := &api.Server{DB: db, Engine: eng, Helper: helper.Client{Socket: *sock}, Version: Version, Web: web.FS(),
SetupTokenPath: filepath.Join(*data, "setup-token"), DataDir: *data} SetupTokenPath: filepath.Join(*data, "setup-token"), DataDir: *data, Proxy: px}
hs := &http.Server{ hs := &http.Server{
Addr: *listen, Addr: *listen,
Handler: srv.Handler(), Handler: srv.Handler(),
@@ -162,6 +170,7 @@ func serve(args []string) error {
} }
}() }()
sdNotify("READY=1") sdNotify("READY=1")
px.Start(ctx)
go watchdog(ctx, db) go watchdog(ctx, db)
slog.Info("pilens serving", "listen", *listen, "version", Version) slog.Info("pilens serving", "listen", *listen, "version", Version)
<-ctx.Done() <-ctx.Done()
@@ -231,6 +240,12 @@ func recoverSessions(db *store.Store, dir string) (ids []string) {
return ids return ids
} }
type proxySink struct{ h *capture.Hub }
func (s proxySink) Publish(eventType string, data []byte) {
s.h.Publish(capture.Event{Type: eventType, Data: data})
}
func sdNotify(state string) { func sdNotify(state string) {
addr := os.Getenv("NOTIFY_SOCKET") addr := os.Getenv("NOTIFY_SOCKET")
if addr == "" { if addr == "" {
+182
View File
@@ -0,0 +1,182 @@
package api
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"pilens.dev/pilens/internal/helper"
"pilens.dev/pilens/internal/proxy"
)
// interceptionRoutes wires the Mode-B (authorised HTTPS inspection) endpoints.
func (s *Server) interceptionRoutes(priv func(string, http.HandlerFunc)) {
priv("GET /api/interception", s.getInterception)
priv("PUT /api/interception", s.setInterception)
priv("POST /api/interception/ca/rotate", s.rotateCA)
priv("POST /api/interception/clear", s.clearFlows)
priv("GET /api/interception/ca.crt", s.downloadCA)
priv("GET /api/http", s.httpFlows)
priv("GET /api/http/{id}", s.httpFlow)
priv("GET /api/http/{id}/body/{which}", s.httpBody)
}
func (s *Server) interceptionStatus(ctx context.Context) map[string]any {
cfg := s.Proxy.Config()
running := false
st, herr := s.helperStatus(ctx)
_ = st
// proxy liveness: ask the helper-independent systemd via the health probe is
// overkill; infer from config + a quick check of the flows file growth is not
// reliable. Report configured-enabled; the dashboard shows service health.
running = cfg.Enabled && herr == ""
return map[string]any{
"config": cfg,
"ca": s.Proxy.CAInfo(),
"running": running,
"port": proxy.ListenPort,
"note": "Interception decrypts HTTPS only for devices whose owner has installed and trusted the PiLens CA. " +
"Pinned apps, private trust stores and QUIC are reported as not intercepted, never bypassed. The CA private key is never downloadable.",
}
}
func (s *Server) getInterception(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.interceptionStatus(r.Context()))
}
// proxyEnvPath is read by the pilens-proxy systemd unit via proxy-run.sh.
func (s *Server) proxyDir() string { return filepath.Join(s.DataDir, "proxy") }
func (s *Server) proxyEnv() string { return filepath.Join(s.proxyDir(), "proxy.env") }
func shellQuote(v string) string { return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'" }
func (s *Server) writeProxyEnv(cfg proxy.Config) error {
bodies := "0"
if cfg.CaptureBodies {
bodies = "1"
}
env := strings.Join([]string{
"PILENS_VENV=" + shellQuote("/opt/pilens/proxy-venv"),
fmt.Sprintf("PILENS_PROXY_PORT=%d", proxy.ListenPort),
"PILENS_CONFDIR=" + shellQuote(s.Proxy.ConfDir()),
"PILENS_ADDON=" + shellQuote("/opt/pilens/mitm_addon.py"),
"PILENS_FLOWS=" + shellQuote(filepath.Join(s.proxyDir(), "flows.ndjson")),
"PILENS_BODIES=" + shellQuote(filepath.Join(s.proxyDir(), "bodies")),
"PILENS_CAPTURE_BODIES=" + bodies,
fmt.Sprintf("PILENS_MAX_BODY_KB=%d", cfg.MaxBodyKB),
"PILENS_IGNORE=" + shellQuote(cfg.ExcludeRegex()),
}, "\n") + "\n"
os.MkdirAll(s.proxyDir(), 0o750)
return os.WriteFile(s.proxyEnv(), []byte(env), 0o640)
}
func (s *Server) setInterception(w http.ResponseWriter, r *http.Request) {
var cfg proxy.Config
if !readJSON(w, r, &cfg) {
return
}
if err := cfg.Validate(); err != nil {
httpErr(w, http.StatusBadRequest, err.Error())
return
}
if err := s.Proxy.EnsureCA(); err != nil {
httpErr(w, 500, "CA generation failed: "+err.Error())
return
}
if err := s.writeProxyEnv(cfg); err != nil {
httpErr(w, 500, "writing proxy config failed: "+err.Error())
return
}
if err := s.Proxy.SetConfig(cfg); err != nil {
httpErr(w, 500, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
var res helper.ApplyResult
err := s.Helper.Call(ctx, helper.Request{Op: helper.OpSetInterception, InterceptEnabled: cfg.Enabled, InterceptPort: proxy.ListenPort}, &res)
detail := fmt.Sprintf("enabled=%v capture_bodies=%v excludes=%d", cfg.Enabled, cfg.CaptureBodies, len(cfg.ExcludeHosts))
if err != nil {
s.audit(r, "interception.apply_failed", detail+": "+err.Error())
w.WriteHeader(http.StatusBadGateway)
writeJSON(w, map[string]any{"error": err.Error(), "result": res, "status": s.interceptionStatus(r.Context())})
return
}
s.audit(r, "interception.changed", detail)
writeJSON(w, map[string]any{"result": res, "status": s.interceptionStatus(r.Context())})
}
func (s *Server) rotateCA(w http.ResponseWriter, r *http.Request) {
if s.Proxy.Config().Enabled {
httpErr(w, http.StatusConflict, "disable interception before rotating the CA, so devices are not left mid-session")
return
}
if err := s.Proxy.RotateCA(); err != nil {
httpErr(w, 500, err.Error())
return
}
s.audit(r, "interception.ca_rotated", s.Proxy.CAInfo().SHA256)
writeJSON(w, map[string]any{"ca": s.Proxy.CAInfo()})
}
func (s *Server) downloadCA(w http.ResponseWriter, r *http.Request) {
pem, err := s.Proxy.CACertPEM()
if err != nil {
httpErr(w, http.StatusNotFound, err.Error())
return
}
s.audit(r, "interception.ca_downloaded", s.Proxy.CAInfo().SHA256)
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", `attachment; filename="pilens-ca.crt"`)
w.Write(pem)
}
func (s *Server) clearFlows(w http.ResponseWriter, r *http.Request) {
s.Proxy.Clear()
s.audit(r, "interception.flows_cleared", "")
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) httpFlows(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
client := q.Get("client")
if !proxy.ClientIP(client) {
httpErr(w, http.StatusBadRequest, "invalid client filter")
return
}
flows, err := s.Proxy.Flows(r.Context(), q.Get("q"), client, qint(r, "limit", 1000))
if err != nil {
httpErr(w, 500, err.Error())
return
}
writeJSON(w, flows)
}
func (s *Server) httpFlow(w http.ResponseWriter, r *http.Request) {
f, err := s.Proxy.Flow(r.Context(), r.PathValue("id"))
if err != nil {
httpErr(w, http.StatusNotFound, "flow not found")
return
}
writeJSON(w, f)
}
// httpBody serves a stored body as an opaque download. It is never served with a
// content type that a browser would execute; the UI renders it in a sandbox.
func (s *Server) httpBody(w http.ResponseWriter, r *http.Request) {
body, err := s.Proxy.Body(r.PathValue("id"), r.PathValue("which"))
if err != nil {
httpErr(w, http.StatusNotFound, "body not retained (body capture was off, or it was cleared)")
return
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment")
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
s.audit(r, "interception.body_viewed", r.PathValue("id")+"/"+r.PathValue("which"))
w.Write(body)
}
+3
View File
@@ -21,6 +21,7 @@ import (
"pilens.dev/pilens/internal/capture" "pilens.dev/pilens/internal/capture"
"pilens.dev/pilens/internal/config" "pilens.dev/pilens/internal/config"
"pilens.dev/pilens/internal/helper" "pilens.dev/pilens/internal/helper"
"pilens.dev/pilens/internal/proxy"
"pilens.dev/pilens/internal/store" "pilens.dev/pilens/internal/store"
) )
@@ -39,6 +40,7 @@ type Server struct {
Engine *capture.Engine Engine *capture.Engine
Helper helper.Client Helper helper.Client
Version string Version string
Proxy *proxy.Manager
Web fs.FS // built UI; may be nil in tests Web fs.FS // built UI; may be nil in tests
SetupTokenPath string SetupTokenPath string
DataDir string DataDir string
@@ -82,6 +84,7 @@ func (s *Server) Handler() http.Handler {
s.networkRoutes(priv) s.networkRoutes(priv)
s.captureRoutes(priv) s.captureRoutes(priv)
s.analysisRoutes(priv) s.analysisRoutes(priv)
s.interceptionRoutes(priv)
if s.Web != nil { if s.Web != nil {
files := http.FileServerFS(s.Web) files := http.FileServerFS(s.Web)
+1
View File
@@ -51,3 +51,4 @@ func (h *Hub) Publish(e Event) {
} }
} }
} }
+3
View File
@@ -18,6 +18,8 @@ const DefaultSocket = "/run/pilens/helper.sock"
type Request struct { type Request struct {
Op string `json:"op"` Op string `json:"op"`
Network *config.Network `json:"network,omitempty"` Network *config.Network `json:"network,omitempty"`
InterceptEnabled bool `json:"intercept_enabled,omitempty"`
InterceptPort int `json:"intercept_port,omitempty"`
} }
type Response struct { type Response struct {
@@ -32,6 +34,7 @@ const (
OpStatus = "status" OpStatus = "status"
OpStations = "stations" OpStations = "stations"
OpLeases = "leases" OpLeases = "leases"
OpSetInterception = "set_interception"
) )
type ApplyResult struct { type ApplyResult struct {
+27 -2
View File
@@ -129,11 +129,19 @@ func DnsmasqConf(n config.Network) string {
func q(s string) string { return `"` + s + `"` } // inputs are validated interface names func q(s string) string { return `"` + s + `"` } // inputs are validated interface names
// Intercept describes transparent HTTPS-interception redirection. Port 0 / nil
// means interception is off and no redirect rules are emitted.
type Intercept struct {
Enabled bool
Port int
}
// Nftables renders the complete inet pilens table. The output begins by // Nftables renders the complete inet pilens table. The output begins by
// (re)creating and deleting the table so `nft -f` replaces it atomically while // (re)creating and deleting the table so `nft -f` replaces it atomically while
// leaving every other table untouched. // leaving every other table untouched. ic may be nil (interception off).
func Nftables(n config.Network) string { func Nftables(n config.Network, ic *Intercept) string {
ap, up := q(n.AP.Interface), q(n.Uplink) ap, up := q(n.AP.Interface), q(n.Uplink)
intercept := ic != nil && ic.Enabled && ic.Port > 0
sub := n.LAN.Subnet().String() sub := n.LAN.Subnet().String()
var mgmtIf []string var mgmtIf []string
for _, i := range n.Management.Interfaces { for _, i := range n.Management.Interfaces {
@@ -186,6 +194,12 @@ func Nftables(n config.Network) string {
w("\t\tip daddr %s udp dport 53 accept", n.LAN.Gateway()) w("\t\tip daddr %s udp dport 53 accept", n.LAN.Gateway())
w("\t\tip daddr %s tcp dport 53 accept", n.LAN.Gateway()) w("\t\tip daddr %s tcp dport 53 accept", n.LAN.Gateway())
w("\t\ticmp type { echo-request, destination-unreachable, time-exceeded } accept") w("\t\ticmp type { echo-request, destination-unreachable, time-exceeded } accept")
if intercept {
// Accept ONLY connections the prerouting redirect created (ct status dnat).
// A client connecting directly to the proxy port is not dnat'd, so this
// never turns the proxy into an open proxy reachable by clients.
w("\t\ttcp dport %d ct status dnat accept comment \"redirected to interception proxy\"", ic.Port)
}
w("\t\tcounter drop comment \"AP clients cannot reach the appliance\"") w("\t\tcounter drop comment \"AP clients cannot reach the appliance\"")
w("\t}") w("\t}")
@@ -213,6 +227,17 @@ func Nftables(n config.Network) string {
w("\t\tcounter drop") w("\t\tcounter drop")
w("\t}") w("\t}")
// Transparent interception: redirect AP-client HTTP(S) to the local proxy.
// Only when enabled, and only new connections from the AP interface. The
// proxy's own upstream connections originate on the Pi (not iifname ap), so
// they are never redirected — no loop.
w("\tchain prerouting {")
w("\t\ttype nat hook prerouting priority dstnat; policy accept;")
if intercept {
w("\t\tiifname %s meta l4proto tcp tcp dport { 80, 443 } redirect to :%d", ap, ic.Port)
}
w("\t}")
w("\tchain postrouting {") w("\tchain postrouting {")
w("\t\ttype nat hook postrouting priority srcnat; policy accept;") w("\t\ttype nat hook postrouting priority srcnat; policy accept;")
w("\t\tip saddr %s oifname %s masquerade", sub, up) w("\t\tip saddr %s oifname %s masquerade", sub, up)
+19 -2
View File
@@ -66,7 +66,7 @@ func TestDnsmasq(t *testing.T) {
} }
func TestNftables(t *testing.T) { func TestNftables(t *testing.T) {
s := Nftables(testNet()) s := Nftables(testNet(), nil)
for _, want := range []string{ for _, want := range []string{
"delete table inet pilens", "delete table inet pilens",
`iifname "wlan0" jump ap_forward`, `iifname "wlan0" jump ap_forward`,
@@ -84,7 +84,24 @@ func TestNftables(t *testing.T) {
} }
n := testNet() n := testNet()
n.Forwarding.Mode = "deny" n.Forwarding.Mode = "deny"
if !strings.Contains(Nftables(n), `"forwarding disabled"`) { if !strings.Contains(Nftables(n, nil), `"forwarding disabled"`) {
t.Error("deny mode must drop forwarded traffic") t.Error("deny mode must drop forwarded traffic")
} }
} }
func TestNftablesInterception(t *testing.T) {
off := Nftables(testNet(), nil)
if strings.Contains(off, "redirect to") || strings.Contains(off, "ct status dnat") {
t.Fatal("interception rules present when disabled")
}
on := Nftables(testNet(), &Intercept{Enabled: true, Port: 8081})
for _, want := range []string{
"chain prerouting {",
`iifname "wlan0" meta l4proto tcp tcp dport { 80, 443 } redirect to :8081`,
"tcp dport 8081 ct status dnat accept",
} {
if !strings.Contains(on, want) {
t.Errorf("interception ruleset missing %q", want)
}
}
}
+165
View File
@@ -0,0 +1,165 @@
// Package proxy manages optional, authorised HTTPS interception: a locally
// generated CA that the operator installs on test devices by hand, a mitmdump
// service, and ingestion of the decrypted HTTP transactions.
//
// Consent is the whole design. The CA private key never leaves the appliance,
// is never pushed to a device, and interception only happens for devices whose
// owner has explicitly installed and trusted the CA. Nothing is covert.
package proxy
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
)
// CAInfo describes the interception CA for display. It never includes the key.
type CAInfo struct {
Present bool `json:"present"`
Subject string `json:"subject"`
SHA256 string `json:"sha256"` // fingerprint, colon-separated
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
Serial string `json:"serial"`
DaysLeft int `json:"days_left"`
KeyType string `json:"key_type"`
}
// caPaths returns the key+cert path (mitmproxy format) and the public cert path.
func caPaths(dir string) (combined, certOnly string) {
return filepath.Join(dir, "mitmproxy-ca.pem"), filepath.Join(dir, "mitmproxy-ca-cert.pem")
}
// EnsureCA creates the interception CA if absent, in the mitmproxy confdir layout.
// RSA-2048 is used (not EC) for the broadest device trust-store compatibility.
func EnsureCA(dir, hostname string) error {
combined, certOnly := caPaths(dir)
if _, err := os.Stat(combined); err == nil {
return nil
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
cn := "PiLens Interception CA"
if hostname != "" {
cn = "PiLens Interception CA (" + hostname + ")"
}
tpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: cn, Organization: []string{"PiLens appliance"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(3, 0, 0),
IsCA: true,
BasicConstraintsValid: true,
MaxPathLen: 0,
MaxPathLenZero: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
if err != nil {
return err
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
// mitmproxy reads the key+cert from mitmproxy-ca.pem (0600) and derives the rest.
if err := writeFile(combined, append(append([]byte{}, keyPEM...), certPEM...), 0o600); err != nil {
return err
}
// Public cert for download / device install (never contains the key).
if err := writeFile(certOnly, certPEM, 0o644); err != nil {
return err
}
writeFile(filepath.Join(dir, "mitmproxy-ca-cert.cer"), certPEM, 0o644)
return nil
}
func writeFile(path string, data []byte, mode os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return err
}
os.Chmod(tmp, mode)
return os.Rename(tmp, path)
}
// PublicCertPEM returns the CA's public certificate (safe to distribute). It never
// returns the private key.
func PublicCertPEM(dir string) ([]byte, error) {
_, certOnly := caPaths(dir)
b, err := os.ReadFile(certOnly)
if err != nil {
return nil, errors.New("interception CA not generated yet")
}
if strings.Contains(string(b), "PRIVATE KEY") { // defensive: must never happen
return nil, errors.New("refusing to serve a file containing a private key")
}
return b, nil
}
// Info reads the CA metadata for display.
func Info(dir string) CAInfo {
_, certOnly := caPaths(dir)
b, err := os.ReadFile(certOnly)
if err != nil {
return CAInfo{Present: false}
}
blk, _ := pem.Decode(b)
if blk == nil {
return CAInfo{Present: false}
}
c, err := x509.ParseCertificate(blk.Bytes)
if err != nil {
return CAInfo{Present: false}
}
sum := sha256.Sum256(c.Raw)
return CAInfo{
Present: true,
Subject: c.Subject.String(),
SHA256: colonHex(sum[:]),
NotBefore: c.NotBefore.UTC().Format("2006-01-02"),
NotAfter: c.NotAfter.UTC().Format("2006-01-02"),
Serial: c.SerialNumber.Text(16),
DaysLeft: int(time.Until(c.NotAfter).Hours() / 24),
KeyType: "RSA 2048",
}
}
// Rotate deletes the CA so a fresh one is generated. Devices trusting the old CA
// must install the new one; existing interception breaks until they do.
func Rotate(dir, hostname string) error {
combined, certOnly := caPaths(dir)
for _, p := range []string{combined, certOnly, filepath.Join(dir, "mitmproxy-ca-cert.cer"), filepath.Join(dir, "mitmproxy-ca.p12"), filepath.Join(dir, "mitmproxy-ca-cert.p12"), filepath.Join(dir, "mitmproxy-dhparam.pem")} {
os.Remove(p)
}
return EnsureCA(dir, hostname)
}
func colonHex(b []byte) string {
h := strings.ToUpper(hex.EncodeToString(b))
var parts []string
for i := 0; i < len(h); i += 2 {
parts = append(parts, h[i:i+2])
}
return strings.Join(parts, ":")
}
// Fingerprint of a cert file, for the install instructions ("verify this matches").
func Fingerprint(dir string) string {
return fmt.Sprintf("SHA-256 %s", Info(dir).SHA256)
}
+62
View File
@@ -0,0 +1,62 @@
package proxy
import (
"strings"
"testing"
)
func TestCAGeneration(t *testing.T) {
dir := t.TempDir()
if err := EnsureCA(dir, "testpi"); err != nil {
t.Fatal(err)
}
// Public cert must never contain a private key.
pub, err := PublicCertPEM(dir)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(pub), "PRIVATE KEY") {
t.Fatal("public cert leaked the private key")
}
if !strings.Contains(string(pub), "BEGIN CERTIFICATE") {
t.Fatal("not a certificate")
}
info := Info(dir)
if !info.Present || !strings.Contains(info.Subject, "PiLens") || len(info.SHA256) != 95 || info.DaysLeft < 1000 {
t.Fatalf("bad CA info: %+v", info)
}
// Idempotent: second call keeps the same CA.
before := info.SHA256
EnsureCA(dir, "testpi")
if Info(dir).SHA256 != before {
t.Fatal("EnsureCA regenerated an existing CA")
}
// Rotate produces a different CA.
if err := Rotate(dir, "testpi"); err != nil {
t.Fatal(err)
}
if Info(dir).SHA256 == before {
t.Fatal("rotate did not change the CA")
}
}
func TestConfigValidate(t *testing.T) {
bad := []Config{
{MaxBodyKB: -1},
{MaxBodyKB: 999999},
{ExcludeHosts: []string{"ok.com", "bad regex ("}},
{ExcludeHosts: []string{"has space"}},
}
for i, c := range bad {
if c.Validate() == nil {
t.Errorf("case %d accepted invalid config", i)
}
}
good := Config{MaxBodyKB: 256, ExcludeHosts: []string{`(?i)\.apple\.com$`, "example\\.com"}}
if err := good.Validate(); err != nil {
t.Fatal(err)
}
if r := good.ExcludeRegex(); !strings.Contains(r, "apple") || !strings.Contains(r, "|") {
t.Fatalf("exclude regex wrong: %s", r)
}
}
+286
View File
@@ -0,0 +1,286 @@
package proxy
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/netip"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"pilens.dev/pilens/internal/store"
)
// Config is the interception toggle and its options. Stored in settings.
type Config struct {
Enabled bool `json:"enabled"`
CaptureBodies bool `json:"capture_bodies"` // default false: sensitive content off by default
MaxBodyKB int `json:"max_body_kb"`
ExcludeHosts []string `json:"exclude_hosts"` // host regexes NOT decrypted (passed through)
}
func DefaultConfig() Config { return Config{Enabled: false, CaptureBodies: false, MaxBodyKB: 256} }
const ListenPort = 8081
var hostRe = regexp.MustCompile(`^[A-Za-z0-9.*?+()\[\]{}|\\^$_:-]{1,200}$`)
func (c Config) Validate() error {
if c.MaxBodyKB < 0 || c.MaxBodyKB > 10240 {
return errors.New("max_body_kb must be 0-10240")
}
for _, h := range c.ExcludeHosts {
if !hostRe.MatchString(h) {
return errors.New("invalid exclude-host pattern: " + h)
}
if _, err := regexp.Compile(h); err != nil {
return errors.New("invalid exclude-host regex " + h + ": " + err.Error())
}
}
return nil
}
// Sink receives live events for the browser (adapts the capture Hub).
type Sink interface{ Publish(eventType string, data []byte) }
// Flow is one HTTP(S) transaction, as emitted by the mitmdump addon and stored.
type Flow struct {
ID string `json:"id"`
TS float64 `json:"ts"`
Client string `json:"client"`
Method string `json:"method"`
Scheme string `json:"scheme"`
Host string `json:"host"`
Port int `json:"port"`
Path string `json:"path"`
HTTPVersion string `json:"http_version"`
Status int `json:"status"`
Reason string `json:"reason"`
ReqCType string `json:"req_ctype"`
RespCType string `json:"resp_ctype"`
ReqSize int64 `json:"req_size"`
RespSize int64 `json:"resp_size"`
DurationMS int64 `json:"duration_ms"`
TLSVersion string `json:"tls_version"`
SNI string `json:"sni"`
ALPN string `json:"alpn"`
Intercepted bool `json:"intercepted"`
Error string `json:"error"`
WebSocket bool `json:"websocket"`
HasReqBody bool `json:"has_req_body"`
HasRespBody bool `json:"has_resp_body"`
ReqHeaders map[string]string `json:"req_headers"`
RespHeaders map[string]string `json:"resp_headers"`
}
// Manager owns interception state, the flow ingester and queries.
type Manager struct {
DB *store.Store
Dir string // /var/lib/pilens/proxy
Hostname string
Sink Sink
mu sync.Mutex
cfg Config
cancel context.CancelFunc
}
func New(db *store.Store, dir, hostname string, sink Sink) *Manager {
m := &Manager{DB: db, Dir: dir, Hostname: hostname, Sink: sink, cfg: DefaultConfig()}
db.GetJSON("interception", &m.cfg)
return m
}
func (m *Manager) Config() Config { m.mu.Lock(); defer m.mu.Unlock(); return m.cfg }
func (m *Manager) SetConfig(c Config) error {
if err := c.Validate(); err != nil {
return err
}
m.mu.Lock()
m.cfg = c
m.mu.Unlock()
return m.DB.PutJSON("interception", c)
}
func (m *Manager) flowsPath() string { return filepath.Join(m.Dir, "flows.ndjson") }
func (m *Manager) bodiesDir() string { return filepath.Join(m.Dir, "bodies") }
func (m *Manager) ConfDir() string { return filepath.Join(m.Dir, "ca") }
func (m *Manager) EnsureCA() error { return EnsureCA(m.ConfDir(), m.Hostname) }
func (m *Manager) CAInfo() CAInfo { return Info(m.ConfDir()) }
func (m *Manager) CACertPEM() ([]byte, error) { return PublicCertPEM(m.ConfDir()) }
func (m *Manager) RotateCA() error { return Rotate(m.ConfDir(), m.Hostname) }
// Start begins tailing the flow file. It runs for the service lifetime; the
// mitmdump service is started/stopped by the helper, this just ingests.
func (m *Manager) Start(ctx context.Context) {
os.MkdirAll(m.bodiesDir(), 0o750)
ctx, cancel := context.WithCancel(ctx)
m.cancel = cancel
go m.tail(ctx)
}
// tail follows flows.ndjson, inserting new lines. Survives file rotation/truncation.
func (m *Manager) tail(ctx context.Context) {
path := m.flowsPath()
var offset int64
m.DB.GetJSON("interception_flow_offset", &offset)
for ctx.Err() == nil {
f, err := os.Open(path)
if err != nil {
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}
continue
}
if fi, _ := f.Stat(); fi != nil && fi.Size() < offset {
offset = 0 // file was rotated/truncated
}
f.Seek(offset, io.SeekStart)
r := bufio.NewReaderSize(f, 1<<20)
for ctx.Err() == nil {
line, rerr := r.ReadBytes('\n')
if len(line) > 0 && line[len(line)-1] == '\n' {
offset += int64(len(line))
m.ingest(line)
m.DB.PutJSON("interception_flow_offset", offset)
continue
}
_ = rerr // partial/EOF: keep offset, re-read after the wait
break
}
f.Close()
select {
case <-ctx.Done():
return
case <-time.After(500 * time.Millisecond):
}
}
}
func (m *Manager) ingest(line []byte) {
var fl Flow
if err := json.Unmarshal(line, &fl); err != nil || fl.ID == "" {
return
}
rh, _ := json.Marshal(fl.ReqHeaders)
sh, _ := json.Marshal(fl.RespHeaders)
_, err := m.DB.Exec(`INSERT OR REPLACE INTO http_flows
(id,ts,client,method,scheme,host,port,path,http_version,status,reason,req_ctype,resp_ctype,req_size,resp_size,duration_ms,tls_version,sni,alpn,intercepted,error,websocket,has_req_body,has_resp_body,req_headers,resp_headers)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
fl.ID, fl.TS, fl.Client, fl.Method, fl.Scheme, fl.Host, fl.Port, fl.Path, fl.HTTPVersion, fl.Status, fl.Reason,
fl.ReqCType, fl.RespCType, fl.ReqSize, fl.RespSize, fl.DurationMS, fl.TLSVersion, fl.SNI, fl.ALPN,
b2i(fl.Intercepted), fl.Error, b2i(fl.WebSocket), b2i(fl.HasReqBody), b2i(fl.HasRespBody), string(rh), string(sh))
if err != nil {
slog.Warn("http flow insert", "err", err)
return
}
if m.Sink != nil {
m.Sink.Publish("http", line)
}
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
// --- queries ---
const flowCols = `id,ts,client,method,scheme,host,port,path,http_version,status,reason,req_ctype,resp_ctype,req_size,resp_size,duration_ms,tls_version,sni,alpn,intercepted,error,websocket,has_req_body,has_resp_body,req_headers,resp_headers`
func scanFlow(sc interface{ Scan(...any) error }) (Flow, error) {
var f Flow
var rh, sh string
var intercept, ws, hrb, hsb int
err := sc.Scan(&f.ID, &f.TS, &f.Client, &f.Method, &f.Scheme, &f.Host, &f.Port, &f.Path, &f.HTTPVersion, &f.Status, &f.Reason,
&f.ReqCType, &f.RespCType, &f.ReqSize, &f.RespSize, &f.DurationMS, &f.TLSVersion, &f.SNI, &f.ALPN,
&intercept, &f.Error, &ws, &hrb, &hsb, &rh, &sh)
f.Intercepted, f.WebSocket, f.HasReqBody, f.HasRespBody = intercept == 1, ws == 1, hrb == 1, hsb == 1
json.Unmarshal([]byte(rh), &f.ReqHeaders)
json.Unmarshal([]byte(sh), &f.RespHeaders)
return f, err
}
// Flows returns recent transactions, newest first, optionally filtered by a
// substring over host/path/client and a client IP.
func (m *Manager) Flows(ctx context.Context, q, client string, limit int) ([]Flow, error) {
if limit <= 0 || limit > 5000 {
limit = 1000
}
where := "1=1"
args := []any{}
if q != "" {
where += " AND (host LIKE ? OR path LIKE ? OR client LIKE ?)"
like := "%" + q + "%"
args = append(args, like, like, like)
}
if client != "" {
where += " AND client = ?"
args = append(args, client)
}
args = append(args, limit)
rows, err := m.DB.QueryContext(ctx, `SELECT `+flowCols+` FROM http_flows WHERE `+where+` ORDER BY ts DESC LIMIT ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Flow{}
for rows.Next() {
f, err := scanFlow(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func (m *Manager) Flow(ctx context.Context, id string) (Flow, error) {
return scanFlow(m.DB.QueryRowContext(ctx, `SELECT `+flowCols+` FROM http_flows WHERE id=?`, id))
}
// Body returns a stored request/response body. which is "req" or "resp".
// Bodies exist only when body capture was enabled for that flow.
func (m *Manager) Body(id, which string) ([]byte, error) {
if which != "req" && which != "resp" || !flowIDRe.MatchString(id) {
return nil, errors.New("invalid body request")
}
return os.ReadFile(filepath.Join(m.bodiesDir(), id+"."+which))
}
var flowIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// Clear deletes all intercepted HTTP data (flows + bodies). Captures are untouched.
func (m *Manager) Clear() error {
m.DB.Exec(`DELETE FROM http_flows`)
os.RemoveAll(m.bodiesDir())
os.MkdirAll(m.bodiesDir(), 0o750)
os.Truncate(m.flowsPath(), 0)
m.DB.PutJSON("interception_flow_offset", int64(0))
return nil
}
// ClientIP validates a client filter value.
func ClientIP(s string) bool { _, err := netip.ParseAddr(s); return err == nil || s == "" }
// ExcludeRegex joins exclude hosts into one mitmdump --ignore-hosts pattern.
func (c Config) ExcludeRegex() string {
var parts []string
for _, h := range c.ExcludeHosts {
parts = append(parts, "(?:"+h+")")
}
return strings.Join(parts, "|")
}
+33
View File
@@ -0,0 +1,33 @@
-- Intercepted HTTP(S) transactions (Mode B). Populated only while interception
-- is enabled and only for devices that trust the installed CA.
CREATE TABLE http_flows (
id TEXT PRIMARY KEY,
ts REAL NOT NULL,
client TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
scheme TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 0,
path TEXT NOT NULL DEFAULT '',
http_version TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '',
req_ctype TEXT NOT NULL DEFAULT '',
resp_ctype TEXT NOT NULL DEFAULT '',
req_size INTEGER NOT NULL DEFAULT 0,
resp_size INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
tls_version TEXT NOT NULL DEFAULT '',
sni TEXT NOT NULL DEFAULT '',
alpn TEXT NOT NULL DEFAULT '',
intercepted INTEGER NOT NULL DEFAULT 0, -- 1 = decrypted, 0 = passthrough/failed
error TEXT NOT NULL DEFAULT '',
websocket INTEGER NOT NULL DEFAULT 0,
has_req_body INTEGER NOT NULL DEFAULT 0,
has_resp_body INTEGER NOT NULL DEFAULT 0,
req_headers TEXT NOT NULL DEFAULT '{}',
resp_headers TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX http_flows_ts ON http_flows(ts);
CREATE INDEX http_flows_host ON http_flows(host);
CREATE INDEX http_flows_client ON http_flows(client);
+157
View File
@@ -0,0 +1,157 @@
"""PiLens mitmdump addon.
Emits one JSON line per HTTP(S) transaction (and per client TLS failure) to
PILENS_FLOWS. Request/response bodies are written to PILENS_BODIES only when
PILENS_CAPTURE_BODIES=1, size-limited by PILENS_MAX_BODY_KB. Sensitive content
is therefore off by default.
This runs inside the pilens-proxy service's venv; it is not imported anywhere else.
"""
import json
import os
import time
FLOWS = os.environ.get("PILENS_FLOWS", "/var/lib/pilens/proxy/flows.ndjson")
BODIES = os.environ.get("PILENS_BODIES", "/var/lib/pilens/proxy/bodies")
CAPTURE_BODIES = os.environ.get("PILENS_CAPTURE_BODIES", "0") == "1"
MAX_BODY = int(os.environ.get("PILENS_MAX_BODY_KB", "256")) * 1024
def _emit(obj):
try:
with open(FLOWS, "a") as f:
f.write(json.dumps(obj, separators=(",", ":")) + "\n")
f.flush()
except Exception:
pass
def _headers(msg):
out = {}
for k, v in msg.headers.items(multi=True):
out[k] = (out[k] + ", " + v) if k in out else v
return out
def _write_body(flow_id, which, content):
if not CAPTURE_BODIES or not content:
return False
data = content[:MAX_BODY]
try:
os.makedirs(BODIES, exist_ok=True)
with open(os.path.join(BODIES, "%s.%s" % (flow_id, which)), "wb") as f:
f.write(data)
return True
except Exception:
return False
def _client(flow):
try:
return flow.client_conn.peername[0]
except Exception:
return ""
def _tls(flow):
cc = flow.client_conn
sc = flow.server_conn
ver = getattr(cc, "tls_version", None) or getattr(sc, "tls_version", None) or ""
alpn = getattr(cc, "alpn", b"") or b""
try:
alpn = alpn.decode()
except Exception:
alpn = str(alpn)
return ver, getattr(cc, "sni", None) or "", alpn
class PiLens:
def response(self, flow):
req, resp = flow.request, flow.response
ver, sni, alpn = _tls(flow)
dur = 0
try:
dur = int((resp.timestamp_end - req.timestamp_start) * 1000)
except Exception:
pass
has_req = _write_body(flow.id, "req", req.raw_content)
has_resp = _write_body(flow.id, "resp", resp.raw_content)
_emit({
"id": flow.id,
"ts": req.timestamp_start or time.time(),
"client": _client(flow),
"method": req.method,
"scheme": req.scheme,
"host": req.pretty_host,
"port": req.port,
"path": req.path,
"http_version": resp.http_version,
"status": resp.status_code,
"reason": resp.reason or "",
"req_ctype": req.headers.get("content-type", ""),
"resp_ctype": resp.headers.get("content-type", ""),
"req_size": len(req.raw_content or b""),
"resp_size": len(resp.raw_content or b""),
"duration_ms": dur,
"tls_version": ver,
"sni": sni,
"alpn": alpn,
"intercepted": req.scheme == "https",
"error": "",
"websocket": bool(flow.websocket),
"has_req_body": has_req,
"has_resp_body": has_resp,
"req_headers": _headers(req),
"resp_headers": _headers(resp),
})
def error(self, flow):
try:
req = flow.request
ver, sni, alpn = _tls(flow)
_emit({
"id": flow.id,
"ts": (req.timestamp_start if req else None) or time.time(),
"client": _client(flow),
"method": req.method if req else "",
"scheme": req.scheme if req else "",
"host": req.pretty_host if req else "",
"port": req.port if req else 0,
"path": req.path if req else "",
"status": 0,
"tls_version": ver,
"sni": sni,
"alpn": alpn,
"intercepted": False,
"error": str(flow.error) if flow.error else "connection error",
"req_headers": {},
"resp_headers": {},
})
except Exception:
pass
def tls_failed_client(self, data):
# The device refused our certificate: it does not trust the CA, or the
# app pins a specific certificate. Reported honestly, never bypassed.
try:
conn = data.conn
peer = conn.peername[0] if conn.peername else ""
sni = getattr(data.context.client, "sni", None) or getattr(conn, "sni", None) or ""
_emit({
"id": "tlsfail-%s-%d" % (peer.replace(".", "_").replace(":", "_"), int(time.time() * 1000) % 1000000000),
"ts": time.time(),
"client": peer,
"scheme": "https",
"host": sni,
"sni": sni,
"status": 0,
"intercepted": False,
"error": "client TLS failure: device does not trust the PiLens CA, or the app pins its own certificate",
"req_headers": {},
"resp_headers": {},
})
except Exception:
pass
addons = [PiLens()]
+2 -1
View File
@@ -11,7 +11,7 @@ run() { local f="$D/$1"; shift; { echo "\$ $*"; "$@"; } >"$f" 2>&1 || true; }
redact() { sed -E 's/("passphrase"[[:space:]]*:[[:space:]]*")[^"]*/\1REDACTED/g; s/^(psk=).*/\1REDACTED/; s/(setup_token=).*/\1REDACTED/'; } redact() { sed -E 's/("passphrase"[[:space:]]*:[[:space:]]*")[^"]*/\1REDACTED/g; s/^(psk=).*/\1REDACTED/; s/(setup_token=).*/\1REDACTED/'; }
run versions.txt sh -c 'cat /opt/pilens/VERSION; uname -a; cat /etc/os-release; tr -d "\0" </proc/device-tree/model; echo; tshark --version | head -1; nmcli --version; dnsmasq --version | head -1; nft --version' run versions.txt sh -c 'cat /opt/pilens/VERSION; uname -a; cat /etc/os-release; tr -d "\0" </proc/device-tree/model; echo; tshark --version | head -1; nmcli --version; dnsmasq --version | head -1; nft --version'
run services.txt systemctl status --no-pager pilens pilens-helper pilens-firewall pilens-dnsmasq NetworkManager run services.txt systemctl status --no-pager pilens pilens-helper pilens-firewall pilens-dnsmasq pilens-proxy NetworkManager
run journal.txt journalctl --no-pager -n 3000 -u pilens -u pilens-helper -u pilens-firewall -u pilens-dnsmasq run journal.txt journalctl --no-pager -n 3000 -u pilens -u pilens-helper -u pilens-firewall -u pilens-dnsmasq
run journal-nm.txt journalctl --no-pager -n 800 -u NetworkManager run journal-nm.txt journalctl --no-pager -n 800 -u NetworkManager
run nft-pilens.txt nft list table inet pilens run nft-pilens.txt nft list table inet pilens
@@ -20,6 +20,7 @@ run ip.txt sh -c 'ip -br link; ip addr; ip route; ip -6 route; sysctl net.ipv4.i
run nm.txt sh -c 'nmcli general; nmcli device; nmcli connection show' run nm.txt sh -c 'nmcli general; nmcli device; nmcli connection show'
run nm-pilens-ap.txt nmcli connection show pilens-ap run nm-pilens-ap.txt nmcli connection show pilens-ap
run wifi.txt sh -c 'iw dev; iw reg get; iw list' run wifi.txt sh -c 'iw dev; iw reg get; iw list'
run interception.txt sh -c 'ls -la /var/lib/pilens/proxy 2>/dev/null; echo; [ -f /var/lib/pilens/proxy/ca/mitmproxy-ca-cert.pem ] && openssl x509 -in /var/lib/pilens/proxy/ca/mitmproxy-ca-cert.pem -noout -subject -fingerprint -sha256 -dates; /opt/pilens/proxy-venv/bin/mitmdump --version 2>/dev/null | head -1'
run capture.txt sh -c 'getcap /usr/bin/dumpcap; id pilens; ls -la /var/lib/pilens /var/lib/pilens-helper' run capture.txt sh -c 'getcap /usr/bin/dumpcap; id pilens; ls -la /var/lib/pilens /var/lib/pilens-helper'
run resources.txt sh -c 'df -h; free -m; uptime; vcgencmd get_throttled; vcgencmd measure_temp; timedatectl' run resources.txt sh -c 'df -h; free -m; uptime; vcgencmd get_throttled; vcgencmd measure_temp; timedatectl'
cp /var/lib/pilens-helper/dnsmasq.conf "$D/dnsmasq.conf" 2>/dev/null cp /var/lib/pilens-helper/dnsmasq.conf "$D/dnsmasq.conf" 2>/dev/null
+19
View File
@@ -169,14 +169,33 @@ usermod -aG wireshark pilens
systemctl stop pilens 2>/dev/null || true systemctl stop pilens 2>/dev/null || true
mkdir -p "$PREFIX" mkdir -p "$PREFIX"
install -m 0755 "$SRC/bin/pilens" "$SRC/bin/pilens-helper" -D -t "$PREFIX/bin" install -m 0755 "$SRC/bin/pilens" "$SRC/bin/pilens-helper" -D -t "$PREFIX/bin"
install -m 0755 "$SRC/proxy-run.sh" "$PREFIX/proxy-run.sh"
install -m 0644 "$SRC/mitm_addon.py" "$PREFIX/mitm_addon.py"
for f in install.sh uninstall.sh repair.sh diagnose.sh setup.example.yaml VERSION README.md LICENSE; do install -m 0644 "$SRC/$f" "$PREFIX/$f"; done for f in install.sh uninstall.sh repair.sh diagnose.sh setup.example.yaml VERSION README.md LICENSE; do install -m 0644 "$SRC/$f" "$PREFIX/$f"; done
chmod 0755 "$PREFIX"/*.sh chmod 0755 "$PREFIX"/*.sh
rm -rf "$PREFIX/docs"; cp -r "$SRC/docs" "$PREFIX/docs" rm -rf "$PREFIX/docs"; cp -r "$SRC/docs" "$PREFIX/docs"
install -d -o pilens -g pilens -m 0750 "$DATA" install -d -o pilens -g pilens -m 0750 "$DATA"
install -d -o pilens -g pilens -m 0750 "$DATA/proxy"
install -d -o root -g root -m 0700 /var/lib/pilens-helper install -d -o root -g root -m 0700 /var/lib/pilens-helper
install -m 0644 "$SRC"/systemd/*.service /etc/systemd/system/ install -m 0644 "$SRC"/systemd/*.service /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload
# ---------------------------------------------------------------- 16. optional TLS proxy deps
say "Preparing optional HTTPS-interception proxy (mitmproxy)"
VENV="$PREFIX/proxy-venv"
if [ -x "$VENV/bin/mitmdump" ]; then
echo " mitmproxy venv already present"
elif apt-get install -y -qq --no-install-recommends python3-venv >>"$LOG" 2>&1 && python3 -m venv "$VENV" >>"$LOG" 2>&1; then
if "$VENV/bin/pip" install --quiet --only-binary=:all: --upgrade pip >>"$LOG" 2>&1 && "$VENV/bin/pip" install --quiet --only-binary=:all: mitmproxy >>"$LOG" 2>&1; then
chown -R pilens:pilens "$VENV"
echo " mitmproxy installed ($("$VENV/bin/mitmdump" --version 2>/dev/null | head -1))"
else
warn "mitmproxy install failed (needs internet + arm64 wheels). HTTPS interception will be unavailable until you run: sudo $PREFIX/repair.sh"
fi
else
warn "could not create the proxy virtualenv; HTTPS interception will be unavailable (optional feature)"
fi
# ---------------------------------------------------------------- 15. initialise data # ---------------------------------------------------------------- 15. initialise data
say "Initialising database and management certificate" say "Initialising database and management certificate"
INIT=$(runuser -u pilens -- "$PREFIX/bin/pilens" init -data "$DATA" -mgmt-iface "$MGMT_IF" -mgmt-subnet "$MGMT_NET") INIT=$(runuser -u pilens -- "$PREFIX/bin/pilens" init -data "$DATA" -mgmt-iface "$MGMT_IF" -mgmt-subnet "$MGMT_NET")
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# Launches mitmdump for PiLens interception from the core-written env file.
# Runs as the unprivileged 'pilens' user under the pilens-proxy.service unit.
set -eu
ENV=/var/lib/pilens/proxy/proxy.env
[ -f "$ENV" ] || { echo "no proxy.env; interception not configured" >&2; exit 1; }
. "$ENV"
export PILENS_FLOWS PILENS_BODIES PILENS_CAPTURE_BODIES PILENS_MAX_BODY_KB
args=( --mode transparent --listen-host 0.0.0.0 --listen-port "$PILENS_PROXY_PORT"
--set "confdir=$PILENS_CONFDIR" --set block_global=false --set connection_strategy=lazy
--set stream_large_bodies=1m -s "$PILENS_ADDON" -q )
if [ -n "${PILENS_IGNORE:-}" ]; then args+=( --ignore-hosts "$PILENS_IGNORE" ); fi
exec "$PILENS_VENV/bin/mitmdump" "${args[@]}"
+12
View File
@@ -18,6 +18,18 @@ if ! getcap /usr/bin/dumpcap 2>/dev/null | grep -q cap_net_raw; then
fi fi
getcap /usr/bin/dumpcap | grep -q cap_net_raw && ok "dumpcap capture capabilities" || bad "dumpcap lacks capabilities" getcap /usr/bin/dumpcap | grep -q cap_net_raw && ok "dumpcap capture capabilities" || bad "dumpcap lacks capabilities"
# Optional: rebuild the mitmproxy venv if it is missing (HTTPS interception).
VENV=/opt/pilens/proxy-venv
if [ ! -x "$VENV/bin/mitmdump" ]; then
if python3 -m venv "$VENV" >/dev/null 2>&1 && "$VENV/bin/pip" install --quiet --only-binary=:all: mitmproxy >/dev/null 2>&1; then
chown -R pilens:pilens "$VENV"; ok "mitmproxy venv rebuilt"
else
bad "mitmproxy venv missing and reinstall failed (interception unavailable; needs internet)"
fi
else
ok "mitmproxy venv present"
fi
systemctl daemon-reload systemctl daemon-reload
systemctl enable pilens-firewall pilens-helper pilens-dnsmasq pilens >/dev/null 2>&1 systemctl enable pilens-firewall pilens-helper pilens-dnsmasq pilens >/dev/null 2>&1
systemctl reset-failed 'pilens*' 2>/dev/null systemctl reset-failed 'pilens*' 2>/dev/null
+1 -1
View File
@@ -15,7 +15,7 @@ if [ $PURGE = 1 ]; then
[ "$x" = DELETE ] || { echo "not confirmed; keeping data"; PURGE=0; } [ "$x" = DELETE ] || { echo "not confirmed; keeping data"; PURGE=0; }
fi fi
systemctl disable --now pilens pilens-dnsmasq pilens-helper pilens-firewall 2>/dev/null || true systemctl disable --now pilens pilens-proxy pilens-dnsmasq pilens-helper pilens-firewall 2>/dev/null || true
nmcli connection delete pilens-ap 2>/dev/null && echo "removed access-point profile (other Wi-Fi profiles were never modified and will reconnect)" || true nmcli connection delete pilens-ap 2>/dev/null && echo "removed access-point profile (other Wi-Fi profiles were never modified and will reconnect)" || true
rm -f /etc/NetworkManager/system-connections/pilens-ap.nmconnection rm -f /etc/NetworkManager/system-connections/pilens-ap.nmconnection
nft delete table inet pilens 2>/dev/null && echo "removed firewall table inet pilens" || true nft delete table inet pilens 2>/dev/null && echo "removed firewall table inet pilens" || true
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=PiLens HTTPS interception proxy (mitmdump, authorised Mode B)
Documentation=file:/opt/pilens/docs/tls-and-encryption.md
After=network-online.target pilens.service
Wants=network-online.target
PartOf=pilens.service
ConditionPathExists=/var/lib/pilens/proxy/proxy.env
[Service]
Type=simple
User=pilens
Group=pilens
# Transparent mode reads SO_ORIGINAL_DST on redirected sockets; no extra privilege.
ExecStart=/opt/pilens/proxy-run.sh
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/pilens/proxy
ProtectHome=yes
PrivateTmp=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=yes
# started/stopped by the network helper when interception is toggled
View File
+6
View File
@@ -15,6 +15,8 @@ import DnsExplorer from "./pages/DnsExplorer";
import TlsExplorer from "./pages/TlsExplorer"; import TlsExplorer from "./pages/TlsExplorer";
import Statistics from "./pages/Statistics"; import Statistics from "./pages/Statistics";
import Findings from "./pages/Findings"; import Findings from "./pages/Findings";
import HttpExplorer from "./pages/HttpExplorer";
import Certificates from "./pages/Certificates";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
export interface Prefs { theme: "dark" | "light" | "system"; density: "compact" | "comfortable"; scale: number; motion: "system" | "reduce" } export interface Prefs { theme: "dark" | "light" | "system"; density: "compact" | "comfortable"; scale: number; motion: "system" | "reduce" }
@@ -29,12 +31,14 @@ const NAV: [string, string, string][] = [
["/conversations", "Conversations", "⇌"], ["/conversations", "Conversations", "⇌"],
["/dns", "DNS Explorer", "◇"], ["/dns", "DNS Explorer", "◇"],
["/tls", "TLS Explorer", "⛨"], ["/tls", "TLS Explorer", "⛨"],
["/http", "HTTP Explorer", "⇅"],
["/statistics", "Statistics", "▦"], ["/statistics", "Statistics", "▦"],
["/findings", "Findings", "⚑"], ["/findings", "Findings", "⚑"],
["/library", "Capture Library", "▤"], ["/library", "Capture Library", "▤"],
["/devices", "Devices", "⌬"], ["/devices", "Devices", "⌬"],
["", "", ""], ["", "", ""],
["/network", "Network Settings", "⇄"], ["/network", "Network Settings", "⇄"],
["/certificates", "Certificates", "⛉"],
["/health", "System Health", "♥"], ["/health", "System Health", "♥"],
["/settings", "Settings", "⚙"], ["/settings", "Settings", "⚙"],
]; ];
@@ -101,6 +105,8 @@ export default function App() {
if (path.startsWith("/tls")) return <TlsExplorer sessionId={path.split("/")[2] ?? ""} />; if (path.startsWith("/tls")) return <TlsExplorer sessionId={path.split("/")[2] ?? ""} />;
if (path.startsWith("/statistics")) return <Statistics sessionId={path.split("/")[2] ?? ""} />; if (path.startsWith("/statistics")) return <Statistics sessionId={path.split("/")[2] ?? ""} />;
if (path.startsWith("/findings")) return <Findings sessionId={path.split("/")[2] ?? ""} />; if (path.startsWith("/findings")) return <Findings sessionId={path.split("/")[2] ?? ""} />;
if (path.startsWith("/http")) return <HttpExplorer />;
if (path === "/certificates") return <Certificates />;
if (path === "/library") return <Library />; if (path === "/library") return <Library />;
if (path === "/devices") return <Devices />; if (path === "/devices") return <Devices />;
if (path === "/network") return <NetworkPage />; if (path === "/network") return <NetworkPage />;
+12 -1
View File
@@ -56,7 +56,7 @@ let es: EventSource | null = null;
export function connectEvents() { export function connectEvents() {
if (es) return; if (es) return;
es = new EventSource("/api/events"); es = new EventSource("/api/events");
for (const t of ["hello", "packets", "capture", "resync", "ping"]) { for (const t of ["hello", "packets", "capture", "resync", "ping", "http"]) {
es.addEventListener(t, (e) => { es.addEventListener(t, (e) => {
let d: any = {}; let d: any = {};
try { d = JSON.parse((e as MessageEvent).data); } catch { /* ignore */ } try { d = JSON.parse((e as MessageEvent).data); } catch { /* ignore */ }
@@ -98,3 +98,14 @@ export interface TLSRecord { num: number; seg: number; time: number; client: str
export interface CertRecord { num: number; seg: number; time: number; server: string; subject: string; issuer: string; sans: string; not_before: string; not_after: string } export interface CertRecord { num: number; seg: number; time: number; server: string; subject: string; issuer: string; sans: string; not_before: string; not_after: string }
export interface Finding { rule: string; title: string; severity: "info" | "low" | "medium"; count: number; flows: number; examples: number[]; filter: string; note: string } export interface Finding { rule: string; title: string; severity: "info" | "low" | "medium"; count: number; flows: number; examples: number[]; filter: string; note: string }
// ---- Phase 4 interception types ----
export interface CAInfo { present: boolean; subject: string; sha256: string; not_before: string; not_after: string; serial: string; days_left: number; key_type: string }
export interface InterceptionConfig { enabled: boolean; capture_bodies: boolean; max_body_kb: number; exclude_hosts: string[] }
export interface InterceptionStatus { config: InterceptionConfig; ca: CAInfo; running: boolean; port: number; note: string }
export interface HttpFlow {
id: string; ts: number; client: string; method: string; scheme: string; host: string; port: number; path: string;
http_version: string; status: number; reason: string; req_ctype: string; resp_ctype: string; req_size: number; resp_size: number;
duration_ms: number; tls_version: string; sni: string; alpn: string; intercepted: boolean; error: string; websocket: boolean;
has_req_body: boolean; has_resp_body: boolean; req_headers: Record<string,string>; resp_headers: Record<string,string>;
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from "react";
import { api, type ApplyResult, type InterceptionStatus } from "../api";
import { Badge, Modal, Steps, toast, usePoll } from "../ui";
const INSTALL: Record<string, string[]> = {
"iOS / iPadOS": [
"Download the CA to the device (or AirDrop it).",
"Settings opens a profile prompt → Install → enter your passcode.",
"Then Settings → General → About → Certificate Trust Settings → turn ON full trust for ‘PiLens Interception CA’.",
"Without that second step iOS will NOT trust it. This is deliberate on Apple's part.",
],
"Android": [
"Settings → Security → Encryption & credentials → Install a certificate → CA certificate.",
"Pick the downloaded .crt.",
"Note: Android apps only trust user-added CAs if they opt in. Many apps (and all of Chrome's pinned sites) will not be decryptable. That is expected.",
],
"Windows": [
"Double-click the .crt → Install Certificate → Local Machine → ‘Trusted Root Certification Authorities’.",
"Browsers using the system store (Edge, Chrome) will then trust it; Firefox uses its own store (below).",
],
"macOS": [
"Double-click the .crt → add to the ‘login’ or ‘System’ keychain.",
"Open Keychain Access → find ‘PiLens Interception CA’ → Get Info → Trust → ‘Always Trust’.",
],
"Firefox": [
"Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import.",
"Tick ‘Trust this CA to identify websites’.",
],
};
export default function Certificates() {
const [st, err, reload] = usePoll<InterceptionStatus>("/api/interception", 8000);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<ApplyResult | null>(null);
const [applyErr, setApplyErr] = useState("");
const [os, setOs] = useState("iOS / iPadOS");
const [confirmBodies, setConfirmBodies] = useState(false);
const [draft, setDraft] = useState<InterceptionStatus["config"] | null>(null);
const cfg = draft ?? st?.config;
const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(st?.config);
const set = (p: Partial<InterceptionStatus["config"]>) => setDraft({ ...(cfg as any), ...p });
const apply = async (override?: Partial<InterceptionStatus["config"]>) => {
const body = { ...(cfg as any), ...override };
setBusy(true); setApplyErr(""); setResult(null);
try {
const r = await api<{ result: ApplyResult }>("/api/interception", { method: "PUT", body });
setResult(r.result); setDraft(null); reload();
toast(body.enabled ? "Interception enabled" : "Interception disabled");
} catch (e: any) {
setApplyErr(e.message); setResult(e.body?.result ?? null); reload();
}
setBusy(false);
};
const ca = st?.ca;
return (
<div className="page">
<div className="page-head">
<div><h1>Certificates &amp; HTTPS Inspection</h1>
<p>Authorised HTTPS interception (Mode B). Decrypts only for devices whose owner has installed and trusted the CA below.</p></div>
</div>
<div className="banner warn" style={{ marginBottom: "1rem" }}><div>
<b>Consent is required, by design.</b> PiLens can only decrypt a device after <em>you</em> install this CA on it and turn on trust.
A trusted certificate can never be pushed over the network or via DHCP. Pinned apps, private trust stores and QUIC are reported as
not intercepted — never silently bypassed. The CA private key never leaves the appliance and is not downloadable.
</div></div>
{err && <div className="banner bad"><div>{err}</div></div>}
<div className="grid g2">
<div className="card stack">
<h3>Interception</h3>
<label className="inline" style={{ fontSize: "1rem" }}>
<input type="checkbox" checked={!!cfg?.enabled} disabled={busy || !ca?.present}
onChange={(e) => apply({ enabled: e.target.checked })} />
<b>{cfg?.enabled ? "Enabled" : "Disabled"}</b> — redirect AP clients' HTTP/HTTPS through the proxy
</label>
{st && <div className="row">
<Badge kind={st.running ? "ok" : "plain"}>{st.running ? "proxy active" : "proxy stopped"}</Badge>
<span className="hint">transparent, port {st.port}</span>
</div>}
{!ca?.present && <div className="banner bad"><div>The interception CA is not available. Is the mitmproxy venv installed? Run <code>sudo /opt/pilens/repair.sh</code>.</div></div>}
{applyErr && <div className="err">{applyErr}</div>}
{result && <Steps steps={result.steps} />}
<p className="hint">Enabling adds an nftables redirect for ports 80/443 from the Wi-Fi only. The proxy never becomes a direct open proxy for clients (it accepts only redirected connections).</p>
</div>
<div className="card stack">
<h3>The appliance CA</h3>
{ca?.present ? <>
<dl className="kv">
<dt>Subject</dt><dd className="mono">{ca.subject}</dd>
<dt>Key</dt><dd>{ca.key_type}</dd>
<dt>Fingerprint</dt><dd className="mono" style={{ fontSize: ".75rem", wordBreak: "break-all" }}>SHA-256 {ca.sha256}</dd>
<dt>Valid</dt><dd>{ca.not_before} → {ca.not_after} <span className="hint">({ca.days_left} days left)</span></dd>
</dl>
<div className="row">
<a className="btn primary" href="/api/interception/ca.crt" download>Download CA certificate</a>
<button disabled={cfg?.enabled || busy} title={cfg?.enabled ? "Disable interception first" : ""}
onClick={async () => { if (confirm("Rotate the CA? Every device must install the new one; current interception breaks until they do.")) { await api("/api/interception/ca/rotate", { method: "POST" }); reload(); toast("CA rotated"); } }}>Rotate CA</button>
</div>
<p className="hint">Install this on each authorised test device, then verify the fingerprint above matches before trusting it.</p>
</> : <p className="hint">No CA yet.</p>}
</div>
</div>
<div className="card stack" style={{ marginTop: ".9rem" }}>
<h3>Install instructions</h3>
<div className="row" role="tablist">
{Object.keys(INSTALL).map((k) => <button key={k} className={os === k ? "on" : ""} onClick={() => setOs(k)}>{k}</button>)}
</div>
<ol style={{ margin: 0, paddingLeft: "1.2rem" }}>{INSTALL[os].map((s, i) => <li key={i} style={{ marginBottom: ".3rem" }}>{s}</li>)}</ol>
</div>
{cfg && <div className="card stack" style={{ marginTop: ".9rem" }}>
<h3>Options</h3>
<label>Excluded hosts (not decrypted — passed straight through)
<textarea rows={3} className="mono" value={cfg.exclude_hosts.join("\n")} placeholder={"one host/regex per line\n(?i)\\.apple\\.com$\nexample\\.com"}
onChange={(e) => set({ exclude_hosts: e.target.value.split("\n").map((x) => x.trim()).filter(Boolean) })} />
<span className="hint">Use for pinned apps or privacy-sensitive hosts. Matched hosts keep working but stay encrypted.</span>
</label>
<label className="inline">
<input type="checkbox" checked={cfg.capture_bodies} onChange={(e) => { if (e.target.checked) setConfirmBodies(true); else set({ capture_bodies: false }); }} />
Retain request/response bodies on disk
</label>
<div className="row">
<label>Max body size (KiB)<input type="number" min={0} max={10240} value={cfg.max_body_kb} onChange={(e) => set({ max_body_kb: +e.target.value })} /></label>
</div>
{dirty && <div className="row"><button className="primary" disabled={busy} onClick={() => apply()}>Apply options</button>
<button disabled={busy} onClick={() => setDraft(null)}>Discard</button></div>}
</div>}
<Modal open={confirmBodies} title="Retain decrypted bodies?" onClose={() => setConfirmBodies(false)}
footer={<><button onClick={() => setConfirmBodies(false)}>Cancel</button>
<button className="danger" onClick={() => { set({ capture_bodies: true }); setConfirmBodies(false); }}>I understand, retain bodies</button></>}>
<p>Response and request bodies can contain passwords, tokens, messages and personal data in clear text. They will be written to the appliance's disk until you clear them.</p>
<p className="hint">Off by default. Only enable for a specific investigation, and clear the data (HTTP Explorer → Clear) when done. Exports and body views are audited.</p>
</Modal>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { api, onEvent, fmtBytes, fmtTime, type HttpFlow } from "../api";
import { Badge, Modal, copyText, toast, usePersist } from "../ui";
function statusKind(f: HttpFlow): "ok" | "warn" | "bad" | "plain" {
if (f.error) return "bad";
if (f.status >= 500) return "bad";
if (f.status >= 400) return "warn";
if (f.status >= 200 && f.status < 400) return "ok";
return "plain";
}
export default function HttpExplorer() {
const [flows, setFlows] = useState<HttpFlow[]>([]);
const [q, setQ] = useState("");
const [onlyErr, setOnlyErr] = useState(false);
const [live, setLive] = usePersist("http.live", true);
const [sel, setSel] = useState<HttpFlow | null>(null);
const [err, setErr] = useState("");
const liveRef = useRef(live); liveRef.current = live;
const load = () => api<HttpFlow[]>("/api/http?limit=2000").then(setFlows).catch((e) => setErr(e.message));
useEffect(() => { load(); }, []);
useEffect(() => onEvent((type, d: HttpFlow) => {
if (type !== "http" || !liveRef.current || !d.id) return;
setFlows((f) => [d, ...f.filter((x) => x.id !== d.id)].slice(0, 3000));
}), []);
const rows = useMemo(() => flows.filter((f) =>
(!q || `${f.host}${f.path} ${f.client} ${f.method}`.toLowerCase().includes(q.toLowerCase())) &&
(!onlyErr || f.error || f.status >= 400)), [flows, q, onlyErr]);
return (
<div className="page" style={{ display: "flex", flexDirection: "column" }}>
<div className="page-head">
<div><h1>HTTP Explorer</h1><p>Decrypted HTTP(S) transactions from trusting devices. Requires interception (Certificates page).</p></div>
<div className="row">
<button className={live ? "on" : ""} onClick={() => setLive(!live)} title="Append new transactions live">⚡ Live</button>
<button onClick={load}>Refresh</button>
<button className="danger" onClick={async () => { if (confirm("Delete all intercepted HTTP data (flows and any retained bodies)? Captures are untouched.")) { await api("/api/interception/clear", { method: "POST" }); setFlows([]); setSel(null); toast("Cleared"); } }}>Clear</button>
</div>
</div>
<div className="banner info" style={{ marginBottom: ".8rem" }}><div>
Shown here are connections PiLens could decrypt because the device trusts the CA. Pinned/untrusting connections appear as <b>errors</b>, not content. Nothing here is captured from devices that did not opt in.
</div></div>
<div className="row" style={{ marginBottom: ".6rem" }}>
<input placeholder="Filter host / path / client / method" value={q} onChange={(e) => setQ(e.target.value)} style={{ minWidth: 280 }} />
<label className="inline"><input type="checkbox" checked={onlyErr} onChange={(e) => setOnlyErr(e.target.checked)} />Errors only</label>
<span className="hint">{rows.length.toLocaleString()} transactions</span>
</div>
{err && <div className="banner bad"><div>{err}</div></div>}
<div className="card" style={{ padding: 0, flex: 1, overflow: "auto" }}>
<table className="data">
<thead><tr><th>Time</th><th>Client</th><th>Method</th><th>Host</th><th>Path</th><th>Status</th><th>Type</th><th>Size</th><th>Time</th><th></th></tr></thead>
<tbody>
{rows.map((f) => (
<tr key={f.id} style={{ cursor: "pointer" }} onClick={() => setSel(f)}>
<td className="hint" style={{ whiteSpace: "nowrap" }}>{fmtTime(f.ts * 1000).split(", ")[1] ?? fmtTime(f.ts * 1000)}</td>
<td className="mono">{f.client}</td>
<td className="mono">{f.method}{f.websocket && " ⇄"}</td>
<td className="mono">{f.scheme === "https" ? "🔒" : ""}{f.host}</td>
<td className="mono" style={{ maxWidth: 360, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{f.path}</td>
<td>{f.error ? <Badge kind="bad">error</Badge> : <Badge kind={statusKind(f)}>{f.status}</Badge>}</td>
<td className="mono hint">{(f.resp_ctype || f.req_ctype || "").split(";")[0]}</td>
<td className="mono">{fmtBytes(f.resp_size)}</td>
<td className="mono hint">{f.duration_ms} ms</td>
<td>›</td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={10} className="empty">No transactions. Enable interception and install the CA on a test device.</td></tr>}
</tbody>
</table>
</div>
{sel && <FlowDetail flow={sel} onClose={() => setSel(null)} />}
</div>
);
}
function Headers({ h }: { h: Record<string, string> }) {
const entries = Object.entries(h || {});
if (!entries.length) return <div className="hint">none</div>;
return <table className="data"><tbody>{entries.map(([k, v]) => <tr key={k}><td className="mono" style={{ color: "var(--muted)", whiteSpace: "nowrap", verticalAlign: "top" }}>{k}</td><td className="mono" style={{ wordBreak: "break-all" }}>{v}</td></tr>)}</tbody></table>;
}
function FlowDetail({ flow, onClose }: { flow: HttpFlow; onClose: () => void }) {
const [tab, setTab] = useState<"req" | "resp">("resp");
return (
<Modal open title={<span className="mono" style={{ fontSize: ".95rem" }}>{flow.method} {flow.scheme}://{flow.host}{flow.path}</span>} onClose={onClose}
footer={<><button onClick={() => copyText(`${flow.method} ${flow.scheme}://${flow.host}${flow.path}`)}>Copy URL</button><button className="primary" onClick={onClose}>Close</button></>}>
<div className="row" style={{ gap: ".5rem", flexWrap: "wrap" }}>
<Badge kind={flow.error ? "bad" : statusKind(flow)}>{flow.error ? "error" : `${flow.status} ${flow.reason}`}</Badge>
{flow.intercepted ? <Badge kind="ok">decrypted</Badge> : <Badge kind="warn">not decrypted</Badge>}
<span className="hint mono">{flow.client} · {flow.http_version} · {flow.duration_ms} ms</span>
</div>
{flow.tls_version && <div className="hint mono">{flow.tls_version}{flow.sni ? ` · SNI ${flow.sni}` : ""}{flow.alpn ? ` · ALPN ${flow.alpn}` : ""}</div>}
{flow.error && <div className="banner bad" style={{ margin: ".5rem 0" }}><div>{flow.error}</div></div>}
<div className="row" style={{ gap: ".25rem", marginTop: ".5rem" }}>
<button className={tab === "req" ? "on" : ""} onClick={() => setTab("req")}>Request</button>
<button className={tab === "resp" ? "on" : ""} onClick={() => setTab("resp")}>Response</button>
</div>
{tab === "req" ? <div className="stack">
<h3>Request headers</h3><Headers h={flow.req_headers} />
<Body flow={flow} which="req" has={flow.has_req_body} size={flow.req_size} ctype={flow.req_ctype} />
</div> : <div className="stack">
<h3>Response headers</h3><Headers h={flow.resp_headers} />
<Body flow={flow} which="resp" has={flow.has_resp_body} size={flow.resp_size} ctype={flow.resp_ctype} />
</div>}
</Modal>
);
}
function Body({ flow, which, has, size, ctype }: { flow: HttpFlow; which: "req" | "resp"; has: boolean; size: number; ctype: string }) {
const [data, setData] = useState<Uint8Array | null>(null);
const [mode, setMode] = useState<"auto" | "text" | "hex" | "image">("auto");
const [loading, setLoading] = useState(false);
const ct = (ctype || "").split(";")[0];
const load = async () => {
setLoading(true);
try {
const r = await fetch(`/api/http/${flow.id}/body/${which}`, { headers: { "X-Requested-With": "pilens" }, credentials: "same-origin" });
if (!r.ok) throw new Error(await r.text());
setData(new Uint8Array(await r.arrayBuffer()));
} catch (e) { toast((e as Error).message, true); }
setLoading(false);
};
if (!has) return <><h3>Body</h3><div className="hint">{size > 0 ? `${fmtBytes(size)} not retained (body capture is off for privacy). Enable it on the Certificates page for a specific investigation.` : "empty"}</div></>;
const view = mode === "auto" ? (ct.startsWith("image/") ? "image" : /json|text|xml|javascript|html|urlencoded/.test(ct) ? "text" : "hex") : mode;
return <><div className="row spread"><h3>Body ({fmtBytes(size)}, {ct || "unknown"})</h3>
<div className="row" style={{ gap: ".25rem" }}>
{["auto", "text", "hex", "image"].map((m) => <button key={m} className={"small" + (mode === m ? " on" : "")} onClick={() => setMode(m as any)}>{m}</button>)}
</div></div>
{!data ? <button disabled={loading} onClick={load}>{loading ? "Loading…" : "Load body"}</button> : <BodyView data={data} view={view} id={flow.id} which={which} ct={ct} />}
</>;
}
function BodyView({ data, view, id, which, ct }: { data: Uint8Array; view: string; id: string; which: string; ct: string }) {
const text = useMemo(() => { try { return new TextDecoder().decode(data); } catch { return ""; } }, [data]);
if (view === "image") {
// Sandboxed: served as application/octet-stream with nosniff; render via blob URL.
const url = useMemo(() => URL.createObjectURL(new Blob([data as BlobPart], { type: ct || "application/octet-stream" })), [data, ct]);
return <img src={url} alt="response body" style={{ maxWidth: "100%", maxHeight: "50vh", background: "var(--panel-2)" }} />;
}
if (view === "hex") {
const lines = [];
for (let o = 0; o < Math.min(data.length, 16384); o += 16) {
const slice = Array.from(data.slice(o, o + 16));
lines.push(o.toString(16).padStart(6, "0") + " " + slice.map((b) => b.toString(16).padStart(2, "0")).join(" ").padEnd(47) + " " + slice.map((b) => b >= 32 && b < 127 ? String.fromCharCode(b) : ".").join(""));
}
return <pre style={preStyle}>{lines.join("\n")}{data.length > 16384 ? "\n… truncated" : ""}</pre>;
}
let pretty = text;
if (/json/.test(ct)) { try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch { /* keep raw */ } }
return <><pre style={preStyle}>{pretty.slice(0, 200000)}{pretty.length > 200000 ? "\n… truncated" : ""}</pre>
<a className="btn small" href={`/api/http/${id}/body/${which}`} download>Download raw</a></>;
}
const preStyle: React.CSSProperties = { maxHeight: "48vh", overflow: "auto", background: "var(--panel-2)", padding: ".6rem", borderRadius: 6, fontSize: ".82rem", whiteSpace: "pre-wrap", wordBreak: "break-all", margin: 0 };