Files
privatae-bastion/client-go/doorfw.go
T
Privatae 48899b41a9
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m58s
ci / fuzz (push) Successful in 3m33s
bastion 0.63.1
2026-11-03 22:56:18 +00:00

1060 lines
35 KiB
Go

package main
// doorfw.go: the door's own inbound rule in this machine's firewall.
//
// A Host connects to the door (hostdoor.go) on TCP 8812, and a firewall on
// this machine that drops inbound connections drops it: Windows Firewall does
// by default on the Public profile. The person never has to touch a firewall,
// so while the door is in use (a pairing whose Host connects here is active,
// or a pairing window is open) the bastion keeps a rule of its own that allows
// the door's port, and when the last such pairing and window are gone it
// removes that rule again. `uninstall` removes it too.
//
// - Windows: an inbound allow rule for TCP <port>, all profiles, for this
// program's own path only, named "Privatae bastion: your Host connects
// (TCP <port>)", made with netsh from System32 by its absolute path.
// - Linux: only a firewall the bastion recognises and that is on: ufw (a
// rule allowing <port>/tcp, with the comment "privatae-bastion: your Host
// connects") or firewalld (the port added to the default zone, now and
// permanently). It never turns a firewall on, off or changes anything else
// in it. Rules of another kind (nftables, iptables) are the machine's own:
// the bastion says once, in status and doctor, what to allow.
// - macOS: the application firewall, when it is on, is told to allow this
// program (socketfilterfw --add, --unblockapp); "block all incoming" is
// the person's choice and is only reported.
//
// A port another rule of the machine already allows is left alone: the
// bastion neither adds a second rule nor removes that one. It removes only the
// rules it recorded adding (door-firewall.json in its state). Every add and
// remove, and a firewall it can't manage, is written to the pairing audit.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"time"
)
const (
// doorFWComment marks the bastion's own ufw rule.
doorFWComment = "privatae-bastion: your Host connects"
// doorFWAnyPort is the port of a rule that allows the program on every
// port (macOS's application firewall is per program, not per port).
doorFWAnyPort = "*"
// doorFWExecWait bounds one firewall command: the daemon calls them.
doorFWExecWait = 30 * time.Second
)
// doorFWRuleName is the Windows Firewall rule for port: recognisably the
// bastion's, and one per port.
func doorFWRuleName(port string) string {
return "Privatae bastion: your Host connects (TCP " + port + ")"
}
// doorFWExec runs one firewall command by its absolute path and returns its
// output. A variable: the suite never runs the machine's own firewall
// (main_test.go), and the tests stand the firewalls in.
var doorFWExec = func(name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), doorFWExecWait)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
hideConsole(cmd)
out, err := cmd.CombinedOutput()
return string(out), err
}
// doorFWHave is the first of these paths that exists, or "".
var doorFWHave = func(paths ...string) string {
for _, p := range paths {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}
// doorFWReadFile reads a firewall's own configuration file.
var doorFWReadFile = os.ReadFile
// doorFWProgram is this program's own path, which a Windows or macOS rule
// names.
var doorFWProgram = resolvedSelf
// doorFWRoot: this process may change the machine's firewall on Linux.
var doorFWRoot = processIsRoot
// doorFirewall is one firewall the bastion can keep its rule in.
type doorFirewall interface {
// id is the firewall in the state file: "windows", "ufw", "firewalld",
// "macos". name is the firewall in the person's words.
id() string
name() string
// has: ours, the bastion's own rule for port is there; other, port is
// already allowed by a rule of the machine's own.
has(port string) (ours, other bool, err error)
add(port string) error
// remove takes out the bastion's own rule for port; prog is the program
// its record names (a Windows or macOS rule is for one program path, and
// the bastion may have moved since it added it).
remove(port, prog string) error
// manual is what the person runs to allow port by hand.
manual(port string) string
}
// doorFirewallFor is this machine's firewall the bastion manages, or nil and
// what the person needs to know ("" when there is nothing to say: no
// firewall that filters inbound connections here was found).
var doorFirewallFor = func(port string) (doorFirewall, string) { return detectDoorFirewall(runtime.GOOS, port) }
// doorFirewallByID is the firewall a recorded rule was added to, to remove it.
var doorFirewallByID = func(id string) doorFirewall { return doorFirewallOf(runtime.GOOS, id) }
func doorFirewallOf(goos, id string) doorFirewall {
switch id {
case "windows":
return winFirewall{netsh: netshPath()}
case "ufw":
if p := doorFWHave("/usr/sbin/ufw", "/sbin/ufw"); p != "" {
return ufwFirewall{bin: p}
}
case "firewalld":
if p := doorFWHave("/usr/bin/firewall-cmd", "/bin/firewall-cmd", "/usr/sbin/firewall-cmd"); p != "" {
return firewalldFirewall{bin: p}
}
case "macos":
return macFirewall{bin: macSocketFilter}
}
return nil
}
// netshPath is netsh.exe in System32, by its absolute path.
func netshPath() string {
return filepath.Join(systemDir(), "netsh.exe")
}
const macSocketFilter = "/usr/libexec/ApplicationFirewall/socketfilterfw"
// detectDoorFirewall finds the firewall of goos that filters inbound
// connections here.
func detectDoorFirewall(goos, port string) (doorFirewall, string) {
switch goos {
case "windows":
return winFirewall{netsh: netshPath()}, ""
case "darwin":
return detectMacFirewall()
case "linux":
return detectLinuxFirewall(port)
}
return nil, ""
}
func detectLinuxFirewall(port string) (doorFirewall, string) {
ufw := doorFWHave("/usr/sbin/ufw", "/sbin/ufw")
fwd := doorFWHave("/usr/bin/firewall-cmd", "/bin/firewall-cmd", "/usr/sbin/firewall-cmd")
if !doorFWRoot() {
// ufw and firewalld take root; an account's own bastion can only say so.
if ufw != "" {
if raw, err := doorFWReadFile("/etc/ufw/ufw.conf"); err == nil && ufwConfEnabled(string(raw)) {
return nil, "ufw is on here, and this bastion runs as an account that cannot change it. Allow TCP " + port + " inbound: " + ufwFirewall{}.manual(port)
}
}
if fwd != "" {
if out, err := doorFWExec(fwd, "--state"); err == nil && strings.TrimSpace(out) == "running" {
return nil, "firewalld is on here, and this bastion runs as an account that cannot change it. Allow TCP " + port + " inbound: " + firewalldFirewall{}.manual(port)
}
}
return nil, ""
}
if ufw != "" {
if out, err := doorFWExec(ufw, "status"); err == nil && ufwActive(out) {
return ufwFirewall{bin: ufw}, ""
}
}
if fwd != "" {
if out, err := doorFWExec(fwd, "--state"); err == nil && strings.TrimSpace(out) == "running" {
return firewalldFirewall{bin: fwd}, ""
}
}
if nft := doorFWHave("/usr/sbin/nft", "/sbin/nft"); nft != "" {
if out, err := doorFWExec(nft, "list", "ruleset"); err == nil && nftFiltersInput(out) {
return nil, "this server filters inbound connections with nftables rules of its own, which the bastion does not change. " +
"Allow TCP " + port + " inbound in the chain on the input hook, for example: nft add rule inet filter input tcp dport " + port + " accept"
}
}
if ipt := doorFWHave("/usr/sbin/iptables", "/sbin/iptables"); ipt != "" {
if out, err := doorFWExec(ipt, "-S", "INPUT"); err == nil && iptablesFiltersInput(out) {
return nil, "this server filters inbound connections with iptables rules of its own, which the bastion does not change. " +
"Allow TCP " + port + " inbound, for example: iptables -I INPUT -p tcp --dport " + port + " -j ACCEPT, kept the way this server keeps its rules"
}
}
return nil, ""
}
func detectMacFirewall() (doorFirewall, string) {
out, err := doorFWExec(macSocketFilter, "--getglobalstate")
if err != nil || !strings.Contains(strings.ToLower(out), "enabled") {
return nil, ""
}
if all, err := doorFWExec(macSocketFilter, "--getblockall"); err == nil && macBlockAll(all) {
return nil, "this Mac's firewall blocks all incoming connections, a setting the bastion does not change. Your Host can connect once " +
"System Settings > Network > Firewall > Options no longer blocks all incoming connections"
}
return macFirewall{bin: macSocketFilter}, ""
}
// ufwConfEnabled reads ENABLED=yes from /etc/ufw/ufw.conf.
func ufwConfEnabled(conf string) bool {
for _, l := range strings.Split(conf, "\n") {
l = strings.TrimSpace(l)
if strings.HasPrefix(l, "ENABLED=") {
v := strings.Trim(strings.TrimPrefix(l, "ENABLED="), `"' `)
return strings.EqualFold(v, "yes")
}
}
return false
}
// ufwActive reads `ufw status`'s first line.
func ufwActive(status string) bool {
return parseUfwStatus(status) == "active"
}
// nftFiltersInput: a chain on the input hook that can drop or reject.
func nftFiltersInput(ruleset string) bool {
if !strings.Contains(ruleset, "hook input") {
return false
}
return strings.Contains(ruleset, "policy drop") || strings.Contains(ruleset, " drop") || strings.Contains(ruleset, " reject")
}
// iptablesFiltersInput: INPUT drops by policy or by a rule.
func iptablesFiltersInput(rules string) bool {
return strings.Contains(rules, "-P INPUT DROP") || strings.Contains(rules, "-j DROP") || strings.Contains(rules, "-j REJECT")
}
func macBlockAll(out string) bool {
o := strings.ToLower(out)
return strings.Contains(o, "block all enabled") || strings.Contains(o, "(state = 1)")
}
// ── the firewalls ────────────────────────────────────────────────────────────
type winFirewall struct{ netsh string }
func (winFirewall) id() string { return "windows" }
func (winFirewall) name() string { return "Windows Firewall" }
func (f winFirewall) has(port string) (bool, bool, error) {
// netsh exits 0 when a rule of that name exists, 1 with "No rules match".
_, err := doorFWExec(f.netsh, "advfirewall", "firewall", "show", "rule", "name="+doorFWRuleName(port))
return err == nil, false, nil
}
func (f winFirewall) add(port string) error {
prog, err := doorFWProgram()
if err != nil {
return fmt.Errorf("this program's path: %w", err)
}
out, err := doorFWExec(f.netsh, "advfirewall", "firewall", "add", "rule",
"name="+doorFWRuleName(port), "dir=in", "action=allow", "protocol=TCP", "localport="+port,
"program="+prog, "profile=any", "enable=yes", "edge=no")
return fwErr(err, out)
}
func (f winFirewall) remove(port, _ string) error {
out, err := doorFWExec(f.netsh, "advfirewall", "firewall", "delete", "rule", "name="+doorFWRuleName(port))
if err != nil && strings.Contains(out, "No rules match") {
return nil
}
return fwErr(err, out)
}
func (winFirewall) manual(port string) string {
return `New-NetFirewallRule -DisplayName "` + doorFWRuleName(port) + `" -Direction Inbound -Protocol TCP -LocalPort ` + port +
` -Program "<path to privatae-bastion.exe>" -Action Allow (from an Administrator PowerShell)`
}
type ufwFirewall struct{ bin string }
func (ufwFirewall) id() string { return "ufw" }
func (ufwFirewall) name() string { return "ufw" }
func (f ufwFirewall) has(port string) (bool, bool, error) {
out, err := doorFWExec(f.bin, "status")
if err != nil {
return false, false, fwErr(err, out)
}
ours, other := ufwRuleFor(out, port)
return ours, other, nil
}
// ufwRuleFor reads `ufw status` for an inbound ALLOW of port/tcp from
// anywhere: ours carries the bastion's comment; other is any other rule that
// lets a Host from anywhere in. An outbound rule ("ALLOW OUT"), a rule for one
// source or one interface lets no Host from anywhere in, so it is neither.
func ufwRuleFor(status, port string) (ours, other bool) {
for _, l := range strings.Split(status, "\n") {
m := ufwRuleLine.FindStringSubmatch(l)
if m == nil || (m[1] != port+"/tcp" && m[1] != port) {
continue
}
if strings.HasSuffix(strings.TrimSpace(l), "# "+doorFWComment) {
ours = true
} else {
other = true
}
}
return ours, other
}
// ufwRuleLine is one `ufw status` rule allowing To in from anywhere: "To",
// an optional " (v6)", "ALLOW" or "ALLOW IN", then "Anywhere".
var ufwRuleLine = regexp.MustCompile(`^(\S+)(?: \(v6\))?\s+ALLOW(?: IN)?\s+Anywhere(?: \(v6\))?(?:\s|$)`)
func (f ufwFirewall) add(port string) error {
out, err := doorFWExec(f.bin, "allow", port+"/tcp", "comment", doorFWComment)
return fwErr(err, out)
}
func (f ufwFirewall) remove(port, _ string) error {
// `ufw delete allow <port>/tcp` deletes the rule whatever its comment, so
// it runs only while the rule there is the bastion's own: one the person
// put in its place (or a record that names another port) is theirs.
if ours, _, err := f.has(port); err != nil {
return err
} else if !ours {
return nil
}
out, err := doorFWExec(f.bin, "delete", "allow", port+"/tcp")
if err != nil && strings.Contains(out, "Could not delete non-existent rule") {
return nil
}
return fwErr(err, out)
}
func (ufwFirewall) manual(port string) string { return "sudo ufw allow " + port + "/tcp" }
type firewalldFirewall struct{ bin string }
func (firewalldFirewall) id() string { return "firewalld" }
func (firewalldFirewall) name() string { return "firewalld" }
// has: firewalld keeps no comment, so a port it allows is the bastion's only
// when the bastion recorded adding it (doorFirewallEnsure reads the record).
func (f firewalldFirewall) has(port string) (bool, bool, error) {
out, err := doorFWExec(f.bin, "--query-port="+port+"/tcp")
if strings.TrimSpace(out) == "yes" {
return false, true, nil
}
if err != nil && strings.TrimSpace(out) != "no" {
return false, false, fwErr(err, out)
}
return false, false, nil
}
func (f firewalldFirewall) add(port string) error {
if out, err := doorFWExec(f.bin, "--add-port="+port+"/tcp"); err != nil {
return fwErr(err, out)
}
out, err := doorFWExec(f.bin, "--permanent", "--add-port="+port+"/tcp")
if err != nil {
// Never a port left open that no record says is the bastion's: it
// would read as the machine's own next time and never be removed.
_, _ = doorFWExec(f.bin, "--remove-port="+port+"/tcp")
}
return fwErr(err, out)
}
func (f firewalldFirewall) remove(port, _ string) error {
out1, err1 := doorFWExec(f.bin, "--remove-port="+port+"/tcp")
out2, err2 := doorFWExec(f.bin, "--permanent", "--remove-port="+port+"/tcp")
// NOT_ENABLED: the port was already gone from that configuration.
if err1 != nil && !strings.Contains(out1, "NOT_ENABLED") {
return fwErr(err1, out1)
}
if err2 != nil && !strings.Contains(out2, "NOT_ENABLED") {
return fwErr(err2, out2)
}
return nil
}
func (firewalldFirewall) manual(port string) string {
return "sudo firewall-cmd --add-port=" + port + "/tcp && sudo firewall-cmd --permanent --add-port=" + port + "/tcp"
}
type macFirewall struct{ bin string }
func (macFirewall) id() string { return "macos" }
func (macFirewall) name() string { return "the macOS application firewall" }
// errDoorFWBlocked: the firewall lists this program and blocks its incoming
// connections, a setting of the person's that the bastion does not change.
var errDoorFWBlocked = errors.New("this program's incoming connections are blocked")
// has: other is this program listed as allowed incoming connections (the
// bastion's own only when it recorded adding it: the application firewall
// keeps no comment). A listed program that is blocked is errDoorFWBlocked.
func (f macFirewall) has(string) (bool, bool, error) {
prog, err := doorFWProgram()
if err != nil {
return false, false, err
}
listed, allowed, err := f.state(prog)
if err != nil {
return false, false, err
}
if listed && !allowed {
return false, false, errDoorFWBlocked
}
return false, allowed, nil
}
// state is prog's entry in --listapps: listed, and allowed incoming
// connections.
func (f macFirewall) state(prog string) (listed, allowed bool, err error) {
out, err := doorFWExec(f.bin, "--listapps")
if err != nil {
return false, false, fwErr(err, out)
}
listed, allowed = macAppState(parseMacListApps(out), prog)
return listed, allowed, nil
}
// macApp is one entry of `socketfilterfw --listapps`.
type macApp struct {
path string // the program's path, or its name alone (macOS 15)
named bool // path is the name alone
allow bool // "Allow incoming connections" (state 1); anything else is not
}
var (
// Before macOS 15: "1 : /usr/sbin/cupsd ( Allow incoming connections )",
// the state on the same line or the next.
macAppLine = regexp.MustCompile(`(?i)^\s*\d+\s*:\s*(.+?)\s*(?:\(\s*(allow|block) incoming connections\s*\))?\s*$`)
macStateLine = regexp.MustCompile(`(?i)^\s*\(\s*(allow|block) incoming connections\s*\)\s*$`)
// macOS 15 lists names, not paths: "cupsd (state: 1)", 1 allowed, 4 blocked.
macNameLine = regexp.MustCompile(`^\s*(.+?)\s*\(state:\s*(\d+)\s*\)\s*$`)
)
// parseMacListApps reads --listapps entry by entry.
func parseMacListApps(out string) []macApp {
var apps []macApp
waiting := -1 // an entry whose state is on the next line
for _, l := range strings.Split(out, "\n") {
if m := macStateLine.FindStringSubmatch(l); m != nil {
if waiting >= 0 {
apps[waiting].allow = strings.EqualFold(m[1], "allow")
waiting = -1
}
continue
}
waiting = -1
if m := macAppLine.FindStringSubmatch(l); m != nil {
apps = append(apps, macApp{path: m[1], allow: strings.EqualFold(m[2], "allow")})
if m[2] == "" {
waiting = len(apps) - 1
}
continue
}
if m := macNameLine.FindStringSubmatch(l); m != nil {
apps = append(apps, macApp{path: m[1], named: true, allow: m[2] == "1"})
}
}
return apps
}
// macAppState: prog is listed, and an entry for it allows incoming
// connections. An entry is prog's when its path is exactly prog's, or, in a
// list of names alone (macOS 15), when its name is exactly prog's.
func macAppState(apps []macApp, prog string) (listed, allowed bool) {
for _, a := range apps {
if a.path == prog || (a.named && a.path == path.Base(prog)) {
listed = true
allowed = allowed || a.allow
}
}
return listed, allowed
}
func (f macFirewall) add(string) error {
prog, err := doorFWProgram()
if err != nil {
return err
}
if out, err := doorFWExec(f.bin, "--add", prog); err != nil {
return fwErr(err, out)
}
out, err := doorFWExec(f.bin, "--unblockapp", prog)
if err != nil {
// As firewalld's: no entry left that no record says is the bastion's.
_, _ = doorFWExec(f.bin, "--remove", prog)
}
return fwErr(err, out)
}
// remove takes out the entry for the program the record names, which is not
// this one when the bastion moved; a record with no program is this one's.
// An entry no longer listed is gone already, and one the person set to block
// is theirs now and stays.
func (f macFirewall) remove(_, prog string) error {
if prog == "" {
p, err := doorFWProgram()
if err != nil {
return err
}
prog = p
}
listed, allowed, err := f.state(prog)
if err != nil {
return err
}
if !listed || !allowed {
return nil
}
out, err := doorFWExec(f.bin, "--remove", prog)
return fwErr(err, out)
}
func (macFirewall) manual(string) string {
return "System Settings > Network > Firewall > Options: allow incoming connections for privatae-bastion"
}
func fwErr(err error, out string) error {
if err == nil {
return nil
}
if o := strings.TrimSpace(out); o != "" {
if len(o) > 300 {
o = o[:300]
}
return fmt.Errorf("%v: %s", err, o)
}
return err
}
// ── what the bastion added: door-firewall.json ───────────────────────────────
type doorFWRule struct {
Firewall string `json:"firewall"`
Port string `json:"port"`
Program string `json:"program,omitempty"`
Added string `json:"added"`
}
type doorFWState struct {
Rules []doorFWRule `json:"rules,omitempty"`
// Allowed: ports another rule of the machine already allows, and in what.
Allowed map[string]string `json:"allowed,omitempty"`
// Notes: per port, what the bastion could not do and the person needs.
Notes map[string]string `json:"notes,omitempty"`
}
var doorFWMu sync.Mutex
func doorFWPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "door-firewall.json"), nil
}
func loadDoorFW() doorFWState {
var st doorFWState
p, err := doorFWPath()
if err != nil {
return st
}
if raw, err := os.ReadFile(p); err == nil && len(raw) < 64<<10 {
_ = json.Unmarshal(raw, &st)
}
return st
}
func saveDoorFW(st doorFWState) error {
p, err := doorFWPath()
if err != nil {
return err
}
raw, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
return atomicWrite(p, raw)
}
// withDoorFW runs fn holding the firewall lock: in this process, and across
// the daemon and a `pair --open` or `unpair` at the terminal.
func withDoorFW(fn func(st *doorFWState) bool) {
doorFWMu.Lock()
defer doorFWMu.Unlock()
if p, err := doorFWPath(); err == nil {
if lf, err := os.OpenFile(p+".lock", os.O_CREATE|os.O_RDWR, 0o600); err == nil {
defer lf.Close()
_ = flockFile(lf)
}
}
st := loadDoorFW()
if fn(&st) {
_ = saveDoorFW(st)
}
}
func (st *doorFWState) rule(id, port string) *doorFWRule {
for i := range st.Rules {
if st.Rules[i].Firewall == id && st.Rules[i].Port == port {
return &st.Rules[i]
}
}
return nil
}
func (st *doorFWState) drop(id, port string) {
out := st.Rules[:0]
for _, r := range st.Rules {
if r.Firewall != id || r.Port != port {
out = append(out, r)
}
}
st.Rules = out
}
func (st *doorFWState) note(port, note string) bool {
if st.Notes == nil {
st.Notes = map[string]string{}
}
if st.Notes[port] == note {
return false
}
if note == "" {
delete(st.Notes, port)
} else {
st.Notes[port] = note
}
return true
}
func (st *doorFWState) allowed(port, by string) {
if st.Allowed == nil {
st.Allowed = map[string]string{}
}
if by == "" {
delete(st.Allowed, port)
} else {
st.Allowed[port] = by
}
}
// ruleKey is the port a firewall's rule is kept for: macOS's is per program.
func ruleKey(f doorFirewall, port string) string {
if f.id() == "macos" {
return doorFWAnyPort
}
return port
}
// doorFirewallEnsure makes sure the door's port is allowed inbound: the door
// on addr is in use. It says, in one line, what holds now.
func doorFirewallEnsure(addr string) string { return doorFirewallEnsureWhile(addr, nil) }
// doorFirewallEnsureWhile is doorFirewallEnsure for a door that may close
// while it runs: inUse, asked holding the firewall lock, false means the door
// closed and its release has run or is waiting for the lock, so nothing is
// added that no release would remove.
func doorFirewallEnsureWhile(addr string, inUse func() bool) string {
port := doorPort(addr)
var said string
withDoorFW(func(st *doorFWState) bool {
if inUse != nil && !inUse() {
return false
}
f, why := doorFirewallFor(port)
if f == nil {
changed := st.note(port, why)
if _, ok := st.Allowed[port]; ok {
st.allowed(port, "")
changed = true
}
if why != "" {
if changed {
pairingAudit("door_firewall_unmanaged", port, "not done: "+why)
}
said = "firewall: " + why
}
return changed
}
key := ruleKey(f, port)
prog, _ := doorFWProgram()
rec := st.rule(f.id(), key)
if rec != nil && prog != "" && rec.Program != prog && (f.id() == "windows" || f.id() == "macos") {
// A Windows or macOS rule is for one program path, and the record
// names another: the bastion moved. That rule is removed by the
// path it names, then this program's own is read and made.
if err := f.remove(port, rec.Program); err != nil {
pairingAudit("door_firewall_failed", port, "not done: removing its rule for "+rec.Program+" from "+f.name()+": "+err.Error())
}
st.drop(f.id(), key)
rec = nil
}
ours, other, err := f.has(port)
if errors.Is(err, errDoorFWBlocked) {
why := f.name() + " is set to block incoming connections for this program, a setting the bastion does not change. " +
"Your Host can connect once it allows them: " + f.manual(port)
if st.note(port, why) {
pairingAudit("door_firewall_unmanaged", port, "not done: "+why)
}
said = "firewall: " + why
return true
}
if err != nil {
why := "the bastion could not read " + f.name() + " (" + err.Error() + "). Allow TCP " + port + " inbound by hand: " + f.manual(port)
if st.note(port, why) {
pairingAudit("door_firewall_failed", port, "not done: "+why)
}
said = "firewall: " + why
return true
}
if rec != nil && !ours && other && f.id() == "ufw" {
// ufw tells its rules apart by comment: the one there now is
// not the bastion's (the person put their own in its place), so
// it is the machine's own, and never removed.
st.drop(f.id(), key)
rec = nil
}
present := ours || other
now := time.Now().UTC().Format(time.RFC3339)
switch {
case rec != nil && present:
st.note(port, "")
said = doorFWWords(f.name(), port, true)
return true
case rec == nil && ours:
// Its own rule (by its name or comment), with no record: kept.
st.Rules = append(st.Rules, doorFWRule{Firewall: f.id(), Port: key, Program: prog, Added: now})
st.note(port, "")
pairingAudit("door_firewall_kept", port, "ok: found its own rule in "+f.name()+" allowing TCP "+port+" inbound, and keeps it")
said = doorFWWords(f.name(), port, true)
return true
case rec == nil && other:
// The machine's own rule: neither doubled nor ever removed.
st.allowed(port, f.name())
st.note(port, "")
said = "firewall: TCP " + port + " is already allowed inbound by a rule of this machine's own in " + f.name() +
"; the bastion leaves it as it is."
return true
case rec != nil:
// Recorded, but no longer there (removed by hand): added again.
st.drop(f.id(), key)
}
if err := f.add(port); err != nil {
why := "the bastion could not add its rule to " + f.name() + " (" + err.Error() + "). Allow TCP " + port + " inbound by hand: " + f.manual(port)
if st.note(port, why) {
pairingAudit("door_firewall_failed", port, "not done: "+why)
}
said = "firewall: " + why
return true
}
st.Rules = append(st.Rules, doorFWRule{Firewall: f.id(), Port: key, Program: prog, Added: now})
st.allowed(port, "")
st.note(port, "")
pairingAudit("door_firewall_opened", port, "ok: added its own rule to "+f.name()+" allowing TCP "+port+
" inbound, while your Host connects to this server")
said = doorFWWords(f.name(), port, false)
return true
})
return said
}
func doorFWWords(fw, port string, kept bool) string {
verb := "added"
if kept {
verb = "keeps"
}
return "firewall: the bastion " + verb + " its own rule in " + fw + " allowing TCP " + port +
" inbound; it is removed when no Host connects here any more. A cloud provider's firewall still needs TCP " + port + " allowed."
}
// doorPortsInUse is every port whose door is in use: an active pairing whose
// Host connects here, an open pairing window, or a window a door claimed for
// a pairing it is still saving (claimedWindowUnderWay: between the claim and
// the save, neither the window nor the pairing is there to read).
func doorPortsInUse() []string {
set := map[string]bool{}
if cfgs, err := loadPairings(); err == nil {
for _, c := range cfgs {
if c.hostDials() && c.active() {
set[doorPort(c.doorAddr())] = true
}
}
}
if w := loadOpenWindow(); w != nil {
set[doorPort(w.Door)] = true
}
if w := claimedWindowUnderWay(time.Now()); w != nil {
set[doorPort(w.Door)] = true
}
return sortedSet(set)
}
func sortedSet(set map[string]bool) []string {
var out []string
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// doorPortWanted: the door on port is in use (doorPortsInUse); doorFWAnyPort,
// any door is.
var doorPortWanted = func(port string) bool {
for _, p := range doorPortsInUse() {
if port == doorFWAnyPort || p == port {
return true
}
}
return false
}
// doorFirewallRelease removes the bastion's own rule for addr's port when the
// door there is no longer in use: no active pairing whose Host connects on
// it and no open window. Called when a door closes, a window ends and a
// pairing goes.
func doorFirewallRelease(addr string) {
port := doorPort(addr)
withDoorFW(func(st *doorFWState) bool {
if doorPortWanted(port) {
return false
}
changed := st.note(port, "")
if _, ok := st.Allowed[port]; ok {
st.allowed(port, "")
changed = true
}
for _, r := range append([]doorFWRule(nil), st.Rules...) {
if r.Port != port && !(r.Port == doorFWAnyPort && !doorPortWanted(doorFWAnyPort)) {
continue
}
if doorFWRemoveRule(st, r, "no Host connects to this server on it any more") {
changed = true
}
}
return changed
})
}
// doorFWRemoveRule removes one recorded rule, auditing it. True when the
// record changed.
func doorFWRemoveRule(st *doorFWState, r doorFWRule, why string) bool {
f := doorFirewallByID(r.Firewall)
if f == nil {
pairingAudit("door_firewall_failed", r.Port, "not done: its rule in "+r.Firewall+" could not be removed: that firewall is no longer on this machine")
st.drop(r.Firewall, r.Port)
return true
}
port := r.Port
if port == doorFWAnyPort {
port = ""
}
if err := f.remove(port, r.Program); err != nil {
why := "the bastion could not remove its rule from " + f.name() + " (" + err.Error() + ")"
if st.note(r.Port, why) {
pairingAudit("door_firewall_failed", r.Port, "not done: "+why)
return true
}
return false
}
st.drop(r.Firewall, r.Port)
what := "TCP " + r.Port
if r.Port == doorFWAnyPort {
what = "this program"
}
pairingAudit("door_firewall_closed", r.Port, "ok: removed its own rule from "+f.name()+" allowing "+what+" inbound: "+why)
return true
}
// doorFirewallRemoveAll removes every rule the bastion added (uninstall).
// Each one removed is said in words.
func doorFirewallRemoveAll() (said []string, err error) {
var errs []string
withDoorFW(func(st *doorFWState) bool {
changed := false
for _, r := range append([]doorFWRule(nil), st.Rules...) {
if doorFWRemoveRule(st, r, "the bastion is uninstalled") {
changed = true
}
if st.rule(r.Firewall, r.Port) != nil {
errs = append(errs, "its rule in "+r.Firewall+" for "+r.Port+": "+st.Notes[r.Port])
continue
}
said = append(said, "removed its own firewall rule ("+r.Firewall+", "+r.Port+")")
}
if len(st.Notes) > 0 || len(st.Allowed) > 0 {
st.Notes, st.Allowed, changed = nil, nil, true
}
return changed
})
if len(errs) > 0 {
return said, errors.New(strings.Join(errs, "; "))
}
return said, nil
}
// ── status and doctor ────────────────────────────────────────────────────────
// statusFirewall is the door's firewall in status: the bastion's own rules,
// ports the machine already allows, and what the person needs to do.
type statusFirewall struct {
Rules []string `json:"rules,omitempty"`
Allowed []string `json:"allowed,omitempty"`
Needs []string `json:"needs,omitempty"`
}
func readDoorFirewallStatus() *statusFirewall {
st := loadDoorFW()
if len(st.Rules) == 0 && len(st.Notes) == 0 && len(st.Allowed) == 0 {
return nil
}
s := &statusFirewall{}
for _, r := range st.Rules {
what := "TCP " + r.Port
if r.Port == doorFWAnyPort {
what = "this program"
}
name := r.Firewall
if f := doorFirewallOf(runtime.GOOS, r.Firewall); f != nil {
name = f.name()
}
s.Rules = append(s.Rules, "the bastion's own rule in "+name+" allows "+what+" inbound (since "+r.Added+")")
}
for _, p := range sortedKeys(st.Allowed) {
s.Allowed = append(s.Allowed, "TCP "+p+" is allowed by a rule of this machine's own in "+st.Allowed[p])
}
for _, p := range sortedKeys(st.Notes) {
s.Needs = append(s.Needs, "TCP "+p+": "+st.Notes[p])
}
return s
}
func sortedKeys(m map[string]string) []string {
var k []string
for x := range m {
k = append(k, x)
}
sort.Strings(k)
return k
}
func printDoorFirewallStatus(w io.Writer, f *statusFirewall) {
if f == nil {
return
}
for _, l := range f.Rules {
fmt.Fprintf(w, "firewall: %s\n", l)
}
for _, l := range f.Allowed {
fmt.Fprintf(w, "firewall: %s\n", l)
}
for _, l := range f.Needs {
fmt.Fprintf(w, "firewall: NEEDS YOU: %s\n", l)
}
}
// doorFirewallChecks are doctor's firewall checks.
func doorFirewallChecks(f *statusFirewall) []doctorCheck {
if f == nil {
return nil
}
var cs []doctorCheck
for _, l := range f.Rules {
cs = append(cs, doctorCheck{Name: "firewall", OK: true, Detail: sentenceCase(l) + "."})
}
for _, l := range f.Allowed {
cs = append(cs, doctorCheck{Name: "firewall", OK: true, Detail: l + "."})
}
for _, l := range f.Needs {
cs = append(cs, doctorCheck{Name: "firewall", Problem: "Your Host connects to this server, and the bastion could not open the way in: " + l + ".",
Fix: "Allow that port inbound as said; the bastion looks again each time its door opens."})
}
return cs
}
// doorFirewallReleaseUnused releases every port the bastion holds a rule or a
// note for (a pairing went while the daemon was not running).
func doorFirewallReleaseUnused() {
st := loadDoorFW()
ports := map[string]bool{}
for _, r := range st.Rules {
if r.Port != doorFWAnyPort {
ports[r.Port] = true
} else {
ports[doorPort(doorDefaultAddr)] = true
}
}
for p := range st.Notes {
ports[p] = true
}
for p := range st.Allowed {
ports[p] = true
}
for p := range ports {
doorFirewallRelease(":" + p)
}
}
// doorFirewallReconcile brings the firewall to what the doors need: every
// port the bastion holds a rule or note for that nothing uses is released, and
// every port in use has its rule ensured again. So a rule that went while its
// door was in use (removed by hand, a firewall reset, or a release that ran
// while a pairing was being saved) is put back, and a port a rule of the
// machine's own allows is still left alone (doorFirewallEnsureWhile).
func doorFirewallReconcile() {
doorFirewallReleaseUnused()
ports := map[string]bool{}
for _, p := range doorPortsInUse() {
ports[p] = true
}
// Recorded rules too: their port is in use when doorPortWanted says so.
for _, r := range loadDoorFW().Rules {
if r.Port != doorFWAnyPort {
ports[r.Port] = true
}
}
for _, p := range sortedSet(ports) {
port := p
doorFirewallEnsureWhile(":"+port, func() bool { return doorPortWanted(port) })
}
}
// doorFWSweepEvery is how often the daemon reconciles the door's rules.
const doorFWSweepEvery = 5 * time.Minute
// doorFirewallSweep reconciles, at start and then every doorFWSweepEvery:
// it removes the rules whose port nothing uses (a `pair --open` that was
// killed before it could, a window that expired with no process left to see
// it) and puts back a rule that went while its port is in use. A state with
// no rule and no note, and no door in use, costs a few file reads.
func doorFirewallSweep(ctx context.Context) {
t := time.NewTicker(doorFWSweepEvery)
defer t.Stop()
for {
doorFirewallReconcile()
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}