Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
@@ -267,7 +267,7 @@ func TestTheManifestGivesEveryJailTheListAndTheWatchItsHealth(t *testing.T) {
|
||||
for _, r := range m.Resources {
|
||||
byID[r["id"].(string)] = r
|
||||
}
|
||||
base, _ := byID["jail-local"]["content"].(string)
|
||||
base := templateText(t, byID["jail-local"])
|
||||
var ignoreip []string
|
||||
for _, l := range strings.Split(base, "\n") {
|
||||
if strings.HasPrefix(l, "ignoreip") {
|
||||
@@ -278,17 +278,20 @@ func TestTheManifestGivesEveryJailTheListAndTheWatchItsHealth(t *testing.T) {
|
||||
t.Errorf("the base ignore list: %q", ignoreip)
|
||||
}
|
||||
for id, r := range byID {
|
||||
if content, _ := r["content"].(string); id != "jail-local" && strings.Contains(content, "ignoreip") {
|
||||
if r["type"] != "file" || id == "jail-local" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(templateText(t, r), "ignoreip") {
|
||||
t.Errorf("%s sets an ignore list of its own, which leaves the operator's own addresses out", id)
|
||||
}
|
||||
}
|
||||
setting := byID["own-addresses"]
|
||||
if setting["path"] != SettingFile || setting["merge"] != "json" || setting["trusted"] != true ||
|
||||
!reflect.DeepEqual(setting["takes-settings"], []any{SettingKey}) {
|
||||
!reflect.DeepEqual(setting["takes-config"], []any{SettingKey}) {
|
||||
t.Errorf("the setting's file: %v", setting)
|
||||
}
|
||||
var content map[string]any
|
||||
if err := json.Unmarshal([]byte(setting["content"].(string)), &content); err != nil ||
|
||||
if err := json.Unmarshal([]byte(templateText(t, setting)), &content); err != nil ||
|
||||
!reflect.DeepEqual(content, map[string]any{SettingKey: []any{}}) {
|
||||
t.Errorf("the setting starts from %v (%v)", content, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's directory, where its manifest and its files/ are.
|
||||
const moduleDir = "../.."
|
||||
|
||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||
func templateText(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
templateText(t, r)
|
||||
}
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" {
|
||||
packs++
|
||||
if a["name"] != "files" {
|
||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/var/log/fail2ban.log {
|
||||
missingok
|
||||
notifempty
|
||||
postrotate
|
||||
/usr/bin/fail2ban-client flushlogs >/dev/null || true
|
||||
endscript
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Fail2Ban action: ban in both INPUT and DOCKER-USER chains
|
||||
# Used by recidive to block repeat offenders from both host and Docker services
|
||||
|
||||
[INCLUDES]
|
||||
|
||||
before = iptables.conf
|
||||
|
||||
[Definition]
|
||||
|
||||
type = allports
|
||||
|
||||
actionstart = { <iptables> -C f2b-<name> -j <returntype> >/dev/null 2>&1; } || { <iptables> -N f2b-<name> || true; <iptables> -A f2b-<name> -j <returntype>; }
|
||||
{ <iptables> -C INPUT -p <protocol> -j f2b-<name> >/dev/null 2>&1; } || { <iptables> -I INPUT -p <protocol> -j f2b-<name>; }
|
||||
{ <iptables> -C DOCKER-USER -p <protocol> -j f2b-<name> >/dev/null 2>&1; } || { <iptables> -I DOCKER-USER -p <protocol> -j f2b-<name>; }
|
||||
|
||||
actionstop = <iptables> -D INPUT -p <protocol> -j f2b-<name> 2>/dev/null || true
|
||||
<iptables> -D DOCKER-USER -p <protocol> -j f2b-<name> 2>/dev/null || true
|
||||
<iptables> -F f2b-<name>
|
||||
<iptables> -X f2b-<name>
|
||||
|
||||
actioncheck = <iptables> -n -L f2b-<name> >/dev/null
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
|
||||
[Init]
|
||||
|
||||
chain = INPUT
|
||||
name = default
|
||||
protocol = tcp
|
||||
blocktype = REJECT --reject-with icmp-port-unreachable
|
||||
returntype = RETURN
|
||||
lockingopt = -w
|
||||
iptables = iptables <lockingopt>
|
||||
|
||||
[Init?family=inet6]
|
||||
|
||||
blocktype = REJECT --reject-with icmp6-port-unreachable
|
||||
iptables = ip6tables <lockingopt>
|
||||
@@ -0,0 +1,48 @@
|
||||
[INCLUDES]
|
||||
|
||||
before = paths-arch.conf
|
||||
|
||||
[DEFAULT]
|
||||
|
||||
# Never act on the machine itself or on a tunnel peer: the mesh's private range is
|
||||
# ${machine:mesh-range}, named here rather than written as a value the module cannot
|
||||
# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.
|
||||
# **A ban list never holds a neighbour.** The mesh's own range is named rather than written
|
||||
# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own
|
||||
# network, not the internet. On a machine behind a router that reflects local traffic, every
|
||||
# client in the house arrives as the gateway's address — so one mistyped local request banned
|
||||
# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186).
|
||||
#
|
||||
# **Nor the operator's own addresses** (novox/hq ADR 0339, issue 238): the public addresses the operator names
|
||||
# as theirs, in the mesh-wide setting own-addresses. An agent polling the forge from the operator's home banned
|
||||
# that home for four weeks on 2026-10-04, and nothing in the house reached the control-node until it was found
|
||||
# among 400 bans. The file is written by this module's watch from the setting, one entry per line; fail2ban
|
||||
# reads it again within a second of a change, so a new value needs no restart. Empty until the operator
|
||||
# fills the setting. A jail that sets its own ignoreip leaves all of this out; the watch says so.
|
||||
ignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10 file:/var/lib/mesh-fail2ban/own-addresses
|
||||
|
||||
# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace
|
||||
# themselves at one try every ten minutes, under any ten-minute window; a day's window counts
|
||||
# them, and a day's ban costs a person who mistyped three times once, from one address, while
|
||||
# the mesh's own range is never banned at all.
|
||||
bantime = 1d
|
||||
findtime = 1d
|
||||
maxretry = 3
|
||||
|
||||
# Ban through iptables, not through a firewall front-end the machine may not have. ufw is
|
||||
# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out
|
||||
# only at ban time: the service reports healthy, the jail counts the attempt, the ban command
|
||||
# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a
|
||||
# machine the mesh reported as protected.
|
||||
#
|
||||
# The action below is this module's own, already used by the recidive jail on every machine
|
||||
# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is
|
||||
# covered too.
|
||||
banaction = iptables-allports-dualchain
|
||||
banaction_allports = iptables-allports-dualchain
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
logpath = %(sshd_log)s
|
||||
backend = %(sshd_backend)s
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"own-addresses": []
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[recidive]
|
||||
enabled = true
|
||||
logpath = /var/log/fail2ban.log
|
||||
# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)
|
||||
banaction = iptables-allports-dualchain
|
||||
# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179).
|
||||
bantime = 4w
|
||||
findtime = 2w
|
||||
maxretry = 2
|
||||
@@ -0,0 +1,8 @@
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
logpath = %(sshd_log)s
|
||||
backend = %(sshd_backend)s
|
||||
maxretry = 3
|
||||
findtime = 1d
|
||||
bantime = 1d
|
||||
@@ -24,6 +24,14 @@
|
||||
"into": "/etc/fail2ban/jail.d/mesh.conf",
|
||||
"filter-into": "/etc/fail2ban/filter.d"
|
||||
},
|
||||
"config": {
|
||||
"own-addresses": {
|
||||
"type": "list",
|
||||
"description": "The addresses and networks outside the mesh that are the operator's own, each an IP address or a CIDR network: every jail ignores them beside the private ranges, the ban verb refuses them, and a failure a jail counted from one is said as a warning; empty is none.",
|
||||
"trusted": true,
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
@@ -42,7 +50,7 @@
|
||||
"path": "/var/lib/mesh-fail2ban/own-addresses",
|
||||
"mode": "0644",
|
||||
"create-once": true,
|
||||
"content": ""
|
||||
"template": "files/own-addresses"
|
||||
},
|
||||
{
|
||||
"id": "jail-d",
|
||||
@@ -73,14 +81,14 @@
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.local",
|
||||
"mode": "0644",
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\n# **A ban list never holds a neighbour.** The mesh's own range is named rather than written\n# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own\n# network, not the internet. On a machine behind a router that reflects local traffic, every\n# client in the house arrives as the gateway's address — so one mistyped local request banned\n# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186).\n#\n# **Nor the operator's own addresses** (novox/hq ADR 0339, issue 238): the public addresses the operator names\n# as theirs, in the mesh-wide setting own-addresses. An agent polling the forge from the operator's home banned\n# that home for four weeks on 2026-10-04, and nothing in the house reached the control-node until it was found\n# among 400 bans. The file is written by this module's watch from the setting, one entry per line; fail2ban\n# reads it again within a second of a change, so a new value needs no restart. Empty until the operator\n# fills the setting. A jail that sets its own ignoreip leaves all of this out; the watch says so.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10 file:/var/lib/mesh-fail2ban/own-addresses\n\n# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace\n# themselves at one try every ten minutes, under any ten-minute window; a day's window counts\n# them, and a day's ban costs a person who mistyped three times once, from one address, while\n# the mesh's own range is never banned at all.\nbantime = 1d\nfindtime = 1d\nmaxretry = 3\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
"template": "files/jail.local"
|
||||
},
|
||||
{
|
||||
"id": "jail-sshd",
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.d/sshd.conf",
|
||||
"mode": "0644",
|
||||
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d\n"
|
||||
"template": "files/sshd.conf"
|
||||
},
|
||||
{
|
||||
"id": "own-addresses",
|
||||
@@ -88,11 +96,11 @@
|
||||
"path": "/etc/fail2ban/mesh-own-addresses.json",
|
||||
"mode": "0644",
|
||||
"merge": "json",
|
||||
"takes-settings": [
|
||||
"takes-config": [
|
||||
"own-addresses"
|
||||
],
|
||||
"trusted": true,
|
||||
"content": "{\n \"own-addresses\": []\n}\n"
|
||||
"template": "files/mesh-own-addresses.json"
|
||||
},
|
||||
{
|
||||
"id": "watch",
|
||||
@@ -118,28 +126,28 @@
|
||||
"path": "/var/log/fail2ban.log",
|
||||
"mode": "0640",
|
||||
"create-once": true,
|
||||
"content": ""
|
||||
"template": "files/own-addresses"
|
||||
},
|
||||
{
|
||||
"id": "jail-recidive",
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.d/recidive.conf",
|
||||
"mode": "0644",
|
||||
"content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\n# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179).\nbantime = 4w\nfindtime = 2w\nmaxretry = 2\n"
|
||||
"template": "files/recidive.conf"
|
||||
},
|
||||
{
|
||||
"id": "action-dualchain",
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/action.d/iptables-allports-dualchain.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Fail2Ban action: ban in both INPUT and DOCKER-USER chains\n# Used by recidive to block repeat offenders from both host and Docker services\n\n[INCLUDES]\n\nbefore = iptables.conf\n\n[Definition]\n\ntype = allports\n\nactionstart = { <iptables> -C f2b-<name> -j <returntype> >/dev/null 2>&1; } || { <iptables> -N f2b-<name> || true; <iptables> -A f2b-<name> -j <returntype>; }\n { <iptables> -C INPUT -p <protocol> -j f2b-<name> >/dev/null 2>&1; } || { <iptables> -I INPUT -p <protocol> -j f2b-<name>; }\n { <iptables> -C DOCKER-USER -p <protocol> -j f2b-<name> >/dev/null 2>&1; } || { <iptables> -I DOCKER-USER -p <protocol> -j f2b-<name>; }\n\nactionstop = <iptables> -D INPUT -p <protocol> -j f2b-<name> 2>/dev/null || true\n <iptables> -D DOCKER-USER -p <protocol> -j f2b-<name> 2>/dev/null || true\n <iptables> -F f2b-<name>\n <iptables> -X f2b-<name>\n\nactioncheck = <iptables> -n -L f2b-<name> >/dev/null\n\nactionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>\n\nactionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>\n\n[Init]\n\nchain = INPUT\nname = default\nprotocol = tcp\nblocktype = REJECT --reject-with icmp-port-unreachable\nreturntype = RETURN\nlockingopt = -w\niptables = iptables <lockingopt>\n\n[Init?family=inet6]\n\nblocktype = REJECT --reject-with icmp6-port-unreachable\niptables = ip6tables <lockingopt>\n"
|
||||
"template": "files/iptables-allports-dualchain.conf"
|
||||
},
|
||||
{
|
||||
"id": "logrotate",
|
||||
"type": "file",
|
||||
"path": "/etc/logrotate.d/fail2ban",
|
||||
"mode": "0644",
|
||||
"content": "/var/log/fail2ban.log {\n missingok\n notifempty\n postrotate\n /usr/bin/fail2ban-client flushlogs >/dev/null || true\n endscript\n}\n"
|
||||
"template": "files/fail2ban"
|
||||
},
|
||||
{
|
||||
"id": "run",
|
||||
@@ -171,6 +179,11 @@
|
||||
"loads": [
|
||||
"fail2ban-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user