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:
+104
-2
@@ -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
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user