Declare openings and a refusal-only guard on an adopted node in place of the filter (hq ADR 0100)
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What an adopted node is declared in place of a filter (novox/hq ADR 0100).
|
||||
//
|
||||
// On an adopted node the firewall found on the machine stays in force: the mesh loads no table
|
||||
// that drops by default or holds an accept. What the mesh needs reachable is declared as
|
||||
// openings, which the host converges through the found firewall in its own terms; and the mesh
|
||||
// guards its own foundation ports itself, in a table that only refuses.
|
||||
|
||||
// AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is
|
||||
// never a module's, so none of it is ever held as found.
|
||||
const AdoptionPrefix = "adoption."
|
||||
|
||||
// Where an opening admits from, on the wire.
|
||||
const (
|
||||
OpeningFromEverywhere = "everywhere"
|
||||
OpeningFromMesh = "mesh"
|
||||
)
|
||||
|
||||
// The two paths a packet reaches a port by: received by the machine, or forwarded to a
|
||||
// container that publishes it.
|
||||
const (
|
||||
PathIncoming = "incoming"
|
||||
PathForwarded = "forwarded"
|
||||
)
|
||||
|
||||
// Guard resources: the refusal-only table, the unit that loads it, and that unit running.
|
||||
const (
|
||||
GuardPath = "/etc/mesh/guard.nft"
|
||||
GuardUnit = "mesh-guard.service"
|
||||
// GuardUnitPath is where the unit is written.
|
||||
GuardUnitPath = "/etc/systemd/system/" + GuardUnit
|
||||
)
|
||||
|
||||
// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer
|
||||
// raises the same three on an adopted genesis, so the first push finds them already there.
|
||||
func GuardID() string { return AdoptionPrefix + "guard" }
|
||||
func GuardUnitID() string { return AdoptionPrefix + "guard-unit" }
|
||||
func GuardRunningID() string { return AdoptionPrefix + "guard-running" }
|
||||
|
||||
// OpeningID is an opening's resource identity: its protocol, port and path say what it is.
|
||||
func OpeningID(protocol string, port int, path string) string {
|
||||
return fmt.Sprintf("%sopening-%s-%d-%s", AdoptionPrefix, protocol, port, path)
|
||||
}
|
||||
|
||||
// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the
|
||||
// filter it would load were the node converged, each from where that filter would admit it.
|
||||
//
|
||||
// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure —
|
||||
// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine
|
||||
// only opens nothing. `published` maps a machine port a container publishes to the container's
|
||||
// port: a published port is forwarded, not received, so its opening names the forwarded path and
|
||||
// the port the packet is forwarded to.
|
||||
func Openings(rules []Rule, foundation []int, published map[string]map[int]int) []map[string]any {
|
||||
type key struct {
|
||||
protocol string
|
||||
port int
|
||||
}
|
||||
from := map[key]string{}
|
||||
var order []key
|
||||
widen := func(k key, f string) {
|
||||
was, seen := from[k]
|
||||
if !seen {
|
||||
order = append(order, k)
|
||||
}
|
||||
if !seen || was != OpeningFromEverywhere {
|
||||
from[k] = f
|
||||
}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
switch rule.From {
|
||||
case FromEverywhere:
|
||||
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
|
||||
case FromMesh:
|
||||
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
||||
}
|
||||
}
|
||||
for _, port := range foundation {
|
||||
widen(key{"tcp", port}, OpeningFromEverywhere)
|
||||
}
|
||||
sort.Slice(order, func(a, b int) bool {
|
||||
if order[a].port != order[b].port {
|
||||
return order[a].port < order[b].port
|
||||
}
|
||||
return order[a].protocol < order[b].protocol
|
||||
})
|
||||
out := make([]map[string]any, 0, len(order))
|
||||
for _, k := range order {
|
||||
opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol,
|
||||
"from": from[k]}
|
||||
if to, forwarded := published[k.protocol][k.port]; forwarded {
|
||||
opening["id"] = OpeningID(k.protocol, k.port, PathForwarded)
|
||||
opening["path"] = PathForwarded
|
||||
opening["to"] = to
|
||||
} else {
|
||||
opening["id"] = OpeningID(k.protocol, k.port, PathIncoming)
|
||||
opening["path"] = PathIncoming
|
||||
}
|
||||
out = append(out, opening)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Published is every port the given containers publish on the machine, by protocol and machine
|
||||
// port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off
|
||||
// the machine reaches it, forwarded or not.
|
||||
func Published(resources []map[string]any) map[string]map[int]int {
|
||||
out := map[string]map[int]int{}
|
||||
for _, r := range resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
listed, _ := r["ports"].([]any)
|
||||
for _, entry := range listed {
|
||||
written := strings.TrimSpace(fmt.Sprint(entry))
|
||||
protocol := "tcp"
|
||||
if cut := strings.LastIndex(written, "/"); cut >= 0 {
|
||||
protocol = written[cut+1:]
|
||||
written = written[:cut]
|
||||
}
|
||||
parts := strings.Split(written, ":")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
if len(parts) == 3 && (parts[0] == "127.0.0.1" || parts[0] == "localhost" ||
|
||||
parts[0] == "[::1]") {
|
||||
continue
|
||||
}
|
||||
outer, err := strconv.Atoi(parts[len(parts)-2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
inner, err := strconv.Atoi(parts[len(parts)-1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if out[protocol] == nil {
|
||||
out[protocol] = map[int]int{}
|
||||
}
|
||||
out[protocol][outer] = inner
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AsGuard renders the mesh's refusal-only table for the given machine ports.
|
||||
//
|
||||
// It passes everything by default and holds nothing but a refusal, so it cannot close anything
|
||||
// the machine serves; and it is the mesh's own table, so the found firewall reloading does not
|
||||
// touch it. It refuses the ports except from the machine itself — its loopback and the container
|
||||
// runtime's own networks — and from the private network, known by the interface a packet arrives
|
||||
// on and never by its source address. At prerouting, ahead of the runtime's destination
|
||||
// translation, so it matches the port the packet was sent to; in the inet family, so both address
|
||||
// families.
|
||||
//
|
||||
// The same text the installer raises on an adopted genesis; a test holds both to it.
|
||||
func AsGuard(ports []int) string {
|
||||
sorted := append([]int{}, ports...)
|
||||
sort.Ints(sorted)
|
||||
listed := make([]string, len(sorted))
|
||||
for i, p := range sorted {
|
||||
listed[i] = strconv.Itoa(p)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("table inet mesh_guard {}\n")
|
||||
b.WriteString("delete table inet mesh_guard\n")
|
||||
b.WriteString("table inet mesh_guard {\n")
|
||||
b.WriteString("\tchain prerouting {\n")
|
||||
b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n")
|
||||
fmt.Fprintf(&b, "\t\tiifname != \"lo\" iifname != \"docker0\" iifname != \"br-*\" "+
|
||||
"iifname != \"mesh0\" tcp dport { %s } drop\n", strings.Join(listed, ", "))
|
||||
b.WriteString("\t}\n")
|
||||
b.WriteString("}\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never
|
||||
// a flush, which would take the container runtime's rules and the found firewall with it.
|
||||
func GuardUnitText() string {
|
||||
return "[Unit]\n" +
|
||||
"Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" +
|
||||
"After=network-pre.target\n" +
|
||||
"Wants=network-pre.target\n" +
|
||||
"\n" +
|
||||
"[Service]\n" +
|
||||
"Type=oneshot\n" +
|
||||
"RemainAfterExit=yes\n" +
|
||||
"ExecStart=nft -f " + GuardPath + "\n" +
|
||||
"ExecReload=nft -f " + GuardPath + "\n" +
|
||||
"ExecStop=nft delete table inet mesh_guard\n" +
|
||||
"\n" +
|
||||
"[Install]\n" +
|
||||
"WantedBy=multi-user.target\n"
|
||||
}
|
||||
|
||||
// GuardResources are the guard as three resources of the existing kinds: the table, the unit, and
|
||||
// the unit running, restarted when the table changes. Nothing when there is nothing to guard: an
|
||||
// empty set is not a table nft loads.
|
||||
func GuardResources(ports []int) []map[string]any {
|
||||
if len(ports) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []map[string]any{
|
||||
{"id": GuardID(), "type": "file", "path": GuardPath, "content": AsGuard(ports),
|
||||
"mode": "0644"},
|
||||
{"id": GuardUnitID(), "type": "file", "path": GuardUnitPath, "content": GuardUnitText(),
|
||||
"mode": "0644"},
|
||||
{"id": GuardRunningID(), "type": "service", "unit": GuardUnit, "state": "running",
|
||||
"boot": "enabled", "restart-on": []any{GuardID(), GuardUnitID()}},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user