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
+104 -2
View File
@@ -142,6 +142,12 @@ func (s *server) handle(ctx context.Context, c *net.UnixConn) {
data = s.status(ctx)
case helper.OpStations:
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:
b, rerr := os.ReadFile(netgen.LeaseFile)
if rerr != nil && !os.IsNotExist(rerr) {
@@ -232,7 +238,7 @@ func (s *server) apply(ctx context.Context, n config.Network) helper.ApplyResult
return r
}
step("validated configuration")
ruleset := netgen.Nftables(n)
ruleset := netgen.Nftables(n, loadIntercept())
tmp := statePath("ruleset.check.nft")
if err := writeAtomic(tmp, []byte(ruleset), 0o600); err != nil {
r.Error = err.Error()
@@ -441,6 +447,98 @@ func stations(ctx context.Context) ([]helper.Station, error) {
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.
func bootLoad() error {
ctx := context.Background()
@@ -462,6 +560,10 @@ func bootLoad() error {
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")
return nil
}
@@ -481,6 +583,6 @@ func renderCmd(path string) int {
fmt.Fprintln(os.Stderr, err)
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
}
+16 -1
View File
@@ -36,6 +36,7 @@ import (
"pilens.dev/pilens/internal/config"
"pilens.dev/pilens/internal/helper"
"pilens.dev/pilens/internal/pcapfile"
"pilens.dev/pilens/internal/proxy"
"pilens.dev/pilens/internal/store"
"pilens.dev/pilens/web"
)
@@ -134,13 +135,20 @@ func serve(args []string) error {
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)
if err != nil {
return err
}
slog.Info("management certificate", "sha256", fp)
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{
Addr: *listen,
Handler: srv.Handler(),
@@ -162,6 +170,7 @@ func serve(args []string) error {
}
}()
sdNotify("READY=1")
px.Start(ctx)
go watchdog(ctx, db)
slog.Info("pilens serving", "listen", *listen, "version", Version)
<-ctx.Done()
@@ -231,6 +240,12 @@ func recoverSessions(db *store.Store, dir string) (ids []string) {
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) {
addr := os.Getenv("NOTIFY_SOCKET")
if addr == "" {