ipadm: Port-Forwarding hinzufügen (-pa/-pl/-pd) + nftables-Include verdrahten
- ipadm verwaltet jetzt auch WAN->LAN Port-Forwards, referenziert per Hostname aus der bestehenden Host-DB (folgt IP-Änderungen automatisch) - ipadm -u generiert zusätzlich /etc/nftables.d/portforward.conf, validiert via 'nft -c -f' und reloadet nftables (Rollback bei ungültiger Config, wie beim dnsmasq-Teil) - /etc/nftables.conf bindet dafür neu /etc/nftables.d/*.conf ein - Host-Store-Locking-Logik in generischen LineStore[T] extrahiert, von Host- und PortForward-Store gemeinsam genutzt
This commit is contained in:
@@ -51,9 +51,11 @@ mehr von Hand in `dnsmasq.d` gepflegt, sondern über das Tool
|
||||
| LAN-Interface | `enp7s0` statisch auf `10.0.0.1/24` | `/etc/network/interfaces` | [`config/network-interfaces`](config/network-interfaces) |
|
||||
| IP-Forwarding | `net.ipv4.ip_forward=1`, persistent über Reboot | `/etc/sysctl.d/99-router-forwarding.conf` | [`config/sysctl.d/99-router-forwarding.conf`](config/sysctl.d/99-router-forwarding.conf) |
|
||||
| NAT | Masquerading `10.0.0.0/24` → raus über `enp3s0` | `/etc/nftables.conf` | [`config/nftables.conf`](config/nftables.conf) |
|
||||
| Port-Forwarding-Include | `/etc/nftables.conf` bindet `/etc/nftables.d/*.conf` ein (dort generiert `ipadm` die Port-Forward-Regeln) | `/etc/nftables.conf` (Zeile am Ende) | [`config/nftables.conf`](config/nftables.conf) |
|
||||
| DHCP + DNS | dnsmasq: DHCP-Pool, Gateway/DNS-Option, DNS-Forwarding | `/etc/dnsmasq.d/lan.conf` | [`config/dnsmasq.d/lan.conf`](config/dnsmasq.d/lan.conf) |
|
||||
| DNS-Includes aktiviert | eine Zeile in `/etc/dnsmasq.conf` einkommentiert: `conf-dir=/etc/dnsmasq.d/,*.conf` | `/etc/dnsmasq.conf` | – (Rest der Datei ist Debian-Standard, nicht gespiegelt) |
|
||||
| Statische Hosts (DHCP-Reservierung + DNS) | von `ipadm` generiert, **nicht** von Hand editieren | `/etc/dnsmasq.d/hosts.conf` (generiert) + `/etc/ipadm/hosts` (Datenbank) | – (live Daten, siehe [`tools/ipadm/`](tools/ipadm/)) |
|
||||
| Port-Forwards (WAN → LAN-Host) | von `ipadm` generiert, **nicht** von Hand editieren | `/etc/nftables.d/portforward.conf` (generiert) + `/etc/ipadm/portforwards` (Datenbank) | – (live Daten, siehe [`tools/ipadm/`](tools/ipadm/)) |
|
||||
| dnsmasq-Resilienz | `Restart=on-failure` (Default war `no`), damit dnsmasq bei einem Boot-Race mit `enp7s0` nicht dauerhaft tot bleibt | `/etc/systemd/system/dnsmasq.service.d/override.conf` | [`config/systemd/dnsmasq.service.d/override.conf`](config/systemd/dnsmasq.service.d/override.conf) |
|
||||
|
||||
Installiertes Paket: `dnsmasq` (übernimmt sowohl DHCP- als auch
|
||||
@@ -141,20 +143,18 @@ address=/server1.lan/10.0.0.10
|
||||
Oder Einträge über `/etc/hosts`-Syntax in einer eigenen Datei einbinden
|
||||
(`addn-hosts=/etc/dnsmasq.d/lan-hosts`).
|
||||
|
||||
### Weitere Firewall-/Portfreigaben (z.B. Port-Forwarding vom WAN ins LAN)
|
||||
### Port-Forwarding (WAN → LAN-Host)
|
||||
|
||||
In `/etc/nftables.conf`, `table inet nat`, eine `chain prerouting` ergänzen,
|
||||
z.B. um Port 8080 vom WAN auf einen LAN-Host weiterzuleiten:
|
||||
Wird über `ipadm` verwaltet, siehe [tools/ipadm/README.md](tools/ipadm/README.md).
|
||||
Kurzform: Zielhost muss zuerst als Host in `ipadm` angelegt sein, dann:
|
||||
|
||||
```
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat;
|
||||
iifname "enp3s0" tcp dport 8080 dnat to 10.0.0.10:8080
|
||||
}
|
||||
```sh
|
||||
ipadm -pa <hostname> <wan-port> [lan-port] [tcp|udp|both]
|
||||
ipadm -u # generiert /etc/nftables.d/portforward.conf neu + reloadet nftables
|
||||
```
|
||||
|
||||
Danach: `nft -c -f /etc/nftables.conf` (Syntaxcheck) und dann
|
||||
`systemctl reload nftables` bzw. `nft -f /etc/nftables.conf`.
|
||||
Nicht mehr von Hand in `/etc/nftables.conf` eintragen — die Datei bindet
|
||||
dafür `/etc/nftables.d/*.conf` ein, das ist der von `ipadm` verwaltete Teil.
|
||||
|
||||
⚠️ Falls künftig auch der WAN-Eingang gefiltert werden soll (aktuell bewusst
|
||||
offen gelassen), unbedingt zuerst SSH-Zugriff (und alle anderen aktiv
|
||||
|
||||
@@ -20,3 +20,6 @@ table inet nat {
|
||||
ip saddr 10.0.0.0/24 oifname "enp3s0" masquerade
|
||||
}
|
||||
}
|
||||
|
||||
# Port-Forwards, verwaltet über `ipadm -pa`/`ipadm -pd` + `ipadm -u` (siehe tools/ipadm im Router-Repo)
|
||||
include "/etc/nftables.d/*.conf"
|
||||
|
||||
+62
-21
@@ -1,10 +1,10 @@
|
||||
# ipadm
|
||||
|
||||
Kleines Go-CLI-Tool zur Verwaltung statischer Hosts (DHCP-Reservierung +
|
||||
DNS-Eintrag) für den dnsmasq-Router auf narcissus. Ersetzt/lehnt sich an das
|
||||
Original-Tool `ipadm` (dhcp/bind management, mwx'2021) an, arbeitet aber
|
||||
gegen unseren dnsmasq-Stack statt isc-dhcp-server/bind9 (siehe
|
||||
[../../README.md](../../README.md)).
|
||||
DNS-Eintrag) und WAN→LAN-Port-Forwards für den dnsmasq/nftables-Router auf
|
||||
narcissus. Ersetzt/lehnt sich an das Original-Tool `ipadm` (dhcp/bind
|
||||
management, mwx'2021) an, arbeitet aber gegen unseren dnsmasq/nftables-Stack
|
||||
statt isc-dhcp-server/bind9 (siehe [../../README.md](../../README.md)).
|
||||
|
||||
Installiert auf dem Server unter `/usr/local/bin/ipadm`.
|
||||
|
||||
@@ -12,18 +12,34 @@ Installiert auf dem Server unter `/usr/local/bin/ipadm`.
|
||||
|
||||
- Pflegt eine flache Textdatei `/etc/ipadm/hosts` (eine Zeile pro Host:
|
||||
`name<TAB>ip<TAB>mac<TAB>comment`, `mac` ist `-` wenn nicht gesetzt) als
|
||||
Datenbank für statische Hosts.
|
||||
- `ipadm -u` generiert daraus `/etc/dnsmasq.d/hosts.conf` (pro Host ein
|
||||
`host-record=` für DNS, plus `dhcp-host=` für Hosts mit MAC-Adresse für die
|
||||
DHCP-Reservierung), validiert die komplette dnsmasq-Konfiguration
|
||||
(`dnsmasq --test`) und lädt dnsmasq bei Erfolg neu
|
||||
(`systemctl reload dnsmasq`). Schlägt die Validierung fehl, wird die
|
||||
vorherige `hosts.conf` automatisch wiederhergestellt und dnsmasq **nicht**
|
||||
neu geladen.
|
||||
Datenbank für statische Hosts, und `/etc/ipadm/portforwards`
|
||||
(`wanport<TAB>proto<TAB>host<TAB>lanport`) für Port-Forwards. Ein
|
||||
Port-Forward referenziert einen Hostnamen aus der Host-Datenbank statt
|
||||
einer festen IP, damit er automatisch der aktuellen IP des Hosts folgt.
|
||||
- `ipadm -u` generiert daraus zwei Dateien und reloadet die zugehörigen
|
||||
Dienste:
|
||||
- `/etc/dnsmasq.d/hosts.conf` (pro Host ein `host-record=` für DNS, plus
|
||||
`dhcp-host=` für Hosts mit MAC-Adresse für die DHCP-Reservierung),
|
||||
validiert mit `dnsmasq --test`, danach `systemctl reload dnsmasq`.
|
||||
- `/etc/nftables.d/portforward.conf` (eine eigene `table inet portforward`
|
||||
mit einer `dnat`-Regel pro Port-Forward, referenzierte Host-IP wird zum
|
||||
Generierungszeitpunkt aus der Host-DB aufgelöst), validiert mit
|
||||
`nft -c -f /etc/nftables.conf` (dafür bindet `/etc/nftables.conf` bereits
|
||||
`include "/etc/nftables.d/*.conf"` ein), danach
|
||||
`systemctl reload nftables`.
|
||||
|
||||
Schlägt die jeweilige Validierung fehl, wird die vorherige generierte
|
||||
Datei automatisch wiederhergestellt und der betroffene Dienst **nicht**
|
||||
neu geladen. Referenziert ein Port-Forward einen mittlerweile gelöschten
|
||||
Host, wird er beim Generieren übersprungen und als Warnung ausgegeben,
|
||||
statt den ganzen Lauf abzubrechen.
|
||||
- IP-Adressen werden bei `-a`/`-i`/im interaktiven Modus geprüft: müssen in
|
||||
`10.0.0.0/24` liegen und **außerhalb** des dynamischen DHCP-Pools
|
||||
`10.0.0.100–10.0.0.200` (verhindert Kollisionen mit dynamisch vergebenen
|
||||
Adressen).
|
||||
- Port-Forwards werden über `(wan-port, protokoll)` eindeutig identifiziert;
|
||||
`both` (= tcp+udp in einer Regel via `meta l4proto { tcp, udp }`) kollidiert
|
||||
dabei mit einem einzelnen `tcp`- oder `udp`-Eintrag auf demselben Port.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -36,13 +52,32 @@ ipadm -i <hostname> <ip address> change ip address
|
||||
ipadm -m <hostname> <mac address> change mac address
|
||||
ipadm -r <old hostname> <new hostname> rename host
|
||||
ipadm -d <hostname> delete host
|
||||
ipadm -u update (dnsmasq-Config regenerieren + reload)
|
||||
ipadm -pa [-f] <hostname> <wan-port> [lan-port] [tcp|udp|both]
|
||||
add port-forward zu bekanntem Host
|
||||
ipadm -pl list port-forwards
|
||||
ipadm -pd <wan-port> [tcp|udp|both] delete port-forward
|
||||
ipadm -u update (dnsmasq+nftables-Config regenerieren + reload)
|
||||
ipadm -h this help
|
||||
```
|
||||
|
||||
Nach jeder Änderung an der Host-DB (`-a`, `-c`, `-i`, `-m`, `-r`, `-d`,
|
||||
interaktiv) muss `ipadm -u` ausgeführt werden, damit dnsmasq die Änderung
|
||||
tatsächlich übernimmt — das Tool weist nach jeder Änderung selbst darauf hin.
|
||||
Beispiel:
|
||||
|
||||
```sh
|
||||
ipadm -a webserver 10.0.0.10 # Host anlegen (falls noch nicht vorhanden)
|
||||
ipadm -pa webserver 443 tcp # WAN-Port 443/tcp -> webserver:443
|
||||
ipadm -pa webserver 8080 80 tcp # WAN-Port 8080/tcp -> webserver:80
|
||||
ipadm -u # anwenden (dnsmasq + nftables)
|
||||
```
|
||||
|
||||
`lan-port` und Protokoll bei `-pa` sind optional und in beliebiger
|
||||
Reihenfolge angebbar (Default `lan-port` = `wan-port`, Default Protokoll
|
||||
`tcp`) — das Tool erkennt selbst, ob ein Token eine Portnummer oder
|
||||
`tcp`/`udp`/`both` ist.
|
||||
|
||||
Nach jeder Änderung an der Host- oder Port-Forward-DB (`-a`, `-c`, `-i`,
|
||||
`-m`, `-r`, `-d`, `-pa`, `-pd`, interaktiv) muss `ipadm -u` ausgeführt
|
||||
werden, damit dnsmasq/nftables die Änderung tatsächlich übernehmen — das
|
||||
Tool weist nach jeder Änderung selbst darauf hin.
|
||||
|
||||
## Bauen / Installieren
|
||||
|
||||
@@ -58,16 +93,22 @@ Alle Pfade sind über Umgebungsvariablen überschreibbar:
|
||||
|
||||
```sh
|
||||
export IPADM_DB=/tmp/test-hosts
|
||||
export IPADM_PORTFWD_DB=/tmp/test-portforwards
|
||||
export IPADM_DNSMASQ_HOSTS=/tmp/test-hosts.conf
|
||||
export IPADM_NFT_PORTFWD=/tmp/test-portforward.conf
|
||||
export IPADM_LAN_CIDR=10.0.0.0/24
|
||||
export IPADM_POOL_START=10.0.0.100
|
||||
export IPADM_POOL_END=10.0.0.200
|
||||
export IPADM_WAN_IFACE=enp3s0
|
||||
./ipadm -a testhost 10.0.0.50 aa:bb:cc:dd:ee:ff
|
||||
./ipadm -l
|
||||
```
|
||||
|
||||
Achtung: `ipadm -u` ruft trotzdem `dnsmasq --test` (prüft die echte
|
||||
System-Konfiguration in `/etc/dnsmasq.conf` + `/etc/dnsmasq.d/`) und bei
|
||||
Erfolg `systemctl reload dnsmasq` auf, auch im Testmodus — das ist
|
||||
beabsichtigt (validiert, dass die generierte Datei mit der echten
|
||||
Umgebung zusammenspielt), aber es reloadet den echten laufenden Dienst.
|
||||
Achtung: `ipadm -u` ruft trotzdem `dnsmasq --test` bzw.
|
||||
`nft -c -f /etc/nftables.conf` auf (prüft die echte System-Konfiguration)
|
||||
und bei Erfolg `systemctl reload dnsmasq`/`systemctl reload nftables`, auch
|
||||
im Testmodus — das ist beabsichtigt (validiert, dass die generierte Datei
|
||||
mit der echten Umgebung zusammenspielt), aber es reloadet die echten
|
||||
laufenden Dienste. `IPADM_NFT_PORTFWD` nur auf einen Pfad außerhalb von
|
||||
`/etc/nftables.d/` zeigen lassen, sonst landet die Testdatei im echten
|
||||
Include und wird real aktiv.
|
||||
|
||||
+17
-76
@@ -1,12 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Host is one static host entry: hostname, IP, optional MAC, free-text comment.
|
||||
@@ -17,50 +14,15 @@ type Host struct {
|
||||
Comment string
|
||||
}
|
||||
|
||||
// Store gives locked read-modify-write access to the flat-file host database.
|
||||
const hostHeader = "# ipadm host database - managed with `ipadm`, do not edit while ipadm is running\n" +
|
||||
"# name\tip\tmac\tcomment\n"
|
||||
|
||||
// One line per host, tab-separated: name\tip\tmac\tcomment
|
||||
// MAC is stored as "-" when empty. Lines starting with '#' and blank lines are ignored.
|
||||
type Store struct {
|
||||
path string
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func openStore(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(dirOf(path), 0755); err != nil {
|
||||
return nil, fmt.Errorf("kann Verzeichnis für %s nicht anlegen: %w", path, err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kann %s nicht öffnen: %w", path, err)
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("kann Lock auf %s nicht setzen: %w", path, err)
|
||||
}
|
||||
return &Store{path: path, file: f}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
syscall.Flock(int(s.file.Fd()), syscall.LOCK_UN)
|
||||
return s.file.Close()
|
||||
}
|
||||
|
||||
func (s *Store) Load() ([]Host, error) {
|
||||
if _, err := s.file.Seek(0, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var hosts []Host
|
||||
sc := bufio.NewScanner(s.file)
|
||||
lineNo := 0
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimRight(sc.Text(), "\r\n")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// MAC is stored as "-" when empty.
|
||||
func parseHostLine(line string) (Host, error) {
|
||||
parts := strings.SplitN(line, "\t", 4)
|
||||
if len(parts) < 3 {
|
||||
return nil, fmt.Errorf("%s:%d: ungültige Zeile (erwarte mind. 3 Tab-getrennte Felder): %q", s.path, lineNo, line)
|
||||
return Host{}, fmt.Errorf("ungültige Zeile (erwarte mind. 3 Tab-getrennte Felder): %q", line)
|
||||
}
|
||||
mac := parts[2]
|
||||
if mac == "-" {
|
||||
@@ -70,46 +32,25 @@ func (s *Store) Load() ([]Host, error) {
|
||||
if len(parts) == 4 {
|
||||
comment = parts[3]
|
||||
}
|
||||
hosts = append(hosts, Host{Name: parts[0], IP: parts[1], MAC: mac, Comment: comment})
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hosts, nil
|
||||
return Host{Name: parts[0], IP: parts[1], MAC: mac, Comment: comment}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Save(hosts []Host) error {
|
||||
sort.Slice(hosts, func(i, j int) bool {
|
||||
return strings.ToLower(hosts[i].Name) < strings.ToLower(hosts[j].Name)
|
||||
})
|
||||
var b strings.Builder
|
||||
b.WriteString("# ipadm host database - managed with `ipadm`, do not edit while ipadm is running\n")
|
||||
b.WriteString("# name\tip\tmac\tcomment\n")
|
||||
for _, h := range hosts {
|
||||
func formatHostLine(h Host) string {
|
||||
mac := h.MAC
|
||||
if mac == "" {
|
||||
mac = "-"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s\t%s\t%s\t%s\n", h.Name, h.IP, mac, h.Comment)
|
||||
}
|
||||
if err := s.file.Truncate(0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.file.Seek(0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.file.WriteString(b.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.file.Sync()
|
||||
return fmt.Sprintf("%s\t%s\t%s\t%s", h.Name, h.IP, mac, h.Comment)
|
||||
}
|
||||
|
||||
func dirOf(path string) string {
|
||||
i := strings.LastIndexByte(path, '/')
|
||||
if i <= 0 {
|
||||
return "."
|
||||
}
|
||||
return path[:i]
|
||||
func openHostStore(path string) (*LineStore[Host], error) {
|
||||
return openLineStore(path, hostHeader, parseHostLine, formatHostLine)
|
||||
}
|
||||
|
||||
func sortHosts(hosts []Host) {
|
||||
sort.Slice(hosts, func(i, j int) bool {
|
||||
return strings.ToLower(hosts[i].Name) < strings.ToLower(hosts[j].Name)
|
||||
})
|
||||
}
|
||||
|
||||
func findHost(hosts []Host, name string) int {
|
||||
|
||||
+229
-31
@@ -1,8 +1,10 @@
|
||||
// ipadm - dhcp/dns management for the dnsmasq-based router setup on this host.
|
||||
//
|
||||
// Maintains a flat-file database of static hosts (hostname, IP, optional
|
||||
// MAC, comment) and, on `ipadm -u`, regenerates the dnsmasq include file
|
||||
// with matching host-record/dhcp-host entries and reloads dnsmasq.
|
||||
// MAC, comment) and of port-forwards (WAN port/proto -> host from that
|
||||
// database). On `ipadm -u`, regenerates the dnsmasq include file
|
||||
// (host-record/dhcp-host entries) and the nftables include file (DNAT
|
||||
// rules), then reloads both services.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -12,7 +14,7 @@ import (
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
const version = "1.0.0"
|
||||
const version = "1.1.0"
|
||||
|
||||
const usage = `ipadm - dhcp/dns management (dnsmasq backend) v` + version + `
|
||||
|
||||
@@ -24,17 +26,22 @@ usage: ipadm <hostname> add/edit host (inter
|
||||
ipadm -m <hostname> <mac address> change mac address
|
||||
ipadm -r <old hostname> <new hostname> rename host
|
||||
ipadm -d <hostname> delete host
|
||||
ipadm -u update (regenerate dnsmasq config + reload)
|
||||
ipadm -pa [-f] <hostname> <wan-port> [lan-port] [tcp|udp|both]
|
||||
add port-forward to a known host
|
||||
ipadm -pl list port-forwards
|
||||
ipadm -pd <wan-port> [tcp|udp|both] delete port-forward
|
||||
ipadm -u update (regenerate dnsmasq+nftables config, reload)
|
||||
ipadm -h this help
|
||||
`
|
||||
|
||||
// netConfig describes the LAN the tool validates static IPs against.
|
||||
// Overridable via env vars, mainly so this can be tested without touching
|
||||
// the real /etc files.
|
||||
// netConfig describes the LAN/WAN the tool validates against. Overridable
|
||||
// via env vars, mainly so this can be tested without touching the real
|
||||
// /etc files.
|
||||
type netConfig struct {
|
||||
lanCIDR string
|
||||
poolStart string
|
||||
poolEnd string
|
||||
wanIface string
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
@@ -48,15 +55,24 @@ func dbPath() string {
|
||||
return envOr("IPADM_DB", "/etc/ipadm/hosts")
|
||||
}
|
||||
|
||||
func portFwdDBPath() string {
|
||||
return envOr("IPADM_PORTFWD_DB", "/etc/ipadm/portforwards")
|
||||
}
|
||||
|
||||
func dnsmasqHostsPath() string {
|
||||
return envOr("IPADM_DNSMASQ_HOSTS", "/etc/dnsmasq.d/hosts.conf")
|
||||
}
|
||||
|
||||
func nftPortFwdPath() string {
|
||||
return envOr("IPADM_NFT_PORTFWD", "/etc/nftables.d/portforward.conf")
|
||||
}
|
||||
|
||||
func loadNetConfig() netConfig {
|
||||
return netConfig{
|
||||
lanCIDR: envOr("IPADM_LAN_CIDR", "10.0.0.0/24"),
|
||||
poolStart: envOr("IPADM_POOL_START", "10.0.0.100"),
|
||||
poolEnd: envOr("IPADM_POOL_END", "10.0.0.200"),
|
||||
wanIface: envOr("IPADM_WAN_IFACE", "enp3s0"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +109,13 @@ func main() {
|
||||
cmdRename(args[1:])
|
||||
case "-d":
|
||||
cmdDelete(args[1:])
|
||||
case "-pa":
|
||||
cmdPortFwdAdd(args[1:])
|
||||
case "-pl":
|
||||
requireArgs(args[1:], 0, "ipadm -pl")
|
||||
cmdPortFwdList()
|
||||
case "-pd":
|
||||
cmdPortFwdDelete(args[1:])
|
||||
default:
|
||||
if strings.HasPrefix(args[0], "-") {
|
||||
fail("unbekannte Option %q\n\n%s", args[0], usage)
|
||||
@@ -112,7 +135,7 @@ func requireArgs(rest []string, want int, form string) {
|
||||
// on success. fn returns the new host list plus a human-readable summary of
|
||||
// what changed (printed on success).
|
||||
func withStore(fn func(hosts []Host) ([]Host, string, error)) {
|
||||
s, err := openStore(dbPath())
|
||||
s, err := openHostStore(dbPath())
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
@@ -128,11 +151,49 @@ func withStore(fn func(hosts []Host) ([]Host, string, error)) {
|
||||
fail("%s", err)
|
||||
}
|
||||
|
||||
sortHosts(newHosts)
|
||||
if err := s.Save(newHosts); err != nil {
|
||||
fail("kann %s nicht schreiben: %s", dbPath(), err)
|
||||
}
|
||||
fmt.Println(summary)
|
||||
fmt.Println("Hinweis: `ipadm -u` ausführen, um dnsmasq zu aktualisieren.")
|
||||
fmt.Println("Hinweis: `ipadm -u` ausführen, um dnsmasq/nftables zu aktualisieren.")
|
||||
}
|
||||
|
||||
// withPortFwdStore mirrors withStore for the port-forward DB. It also loads
|
||||
// the (read-only) host DB so add/delete can validate against known hosts.
|
||||
func withPortFwdStore(fn func(hosts []Host, fwds []PortForward) ([]PortForward, string, error)) {
|
||||
hs, err := openHostStore(dbPath())
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
hosts, err := hs.Load()
|
||||
hs.Close()
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
|
||||
s, err := openPortFwdStore(portFwdDBPath())
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
fwds, err := s.Load()
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
|
||||
newFwds, summary, err := fn(hosts, fwds)
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
|
||||
sortPortFwds(newFwds)
|
||||
if err := s.Save(newFwds); err != nil {
|
||||
fail("kann %s nicht schreiben: %s", portFwdDBPath(), err)
|
||||
}
|
||||
fmt.Println(summary)
|
||||
fmt.Println("Hinweis: `ipadm -u` ausführen, um nftables zu aktualisieren.")
|
||||
}
|
||||
|
||||
func cmdInteractive(name string) {
|
||||
@@ -147,7 +208,7 @@ func cmdInteractive(name string) {
|
||||
}
|
||||
|
||||
func cmdList() {
|
||||
s, err := openStore(dbPath())
|
||||
s, err := openHostStore(dbPath())
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
@@ -160,9 +221,10 @@ func cmdList() {
|
||||
fmt.Println("keine Hosts eingetragen")
|
||||
return
|
||||
}
|
||||
sortHosts(hosts)
|
||||
tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "HOSTNAME\tIP\tMAC\tCOMMENT")
|
||||
for _, h := range sortedCopy(hosts) {
|
||||
for _, h := range hosts {
|
||||
mac := h.MAC
|
||||
if mac == "" {
|
||||
mac = "-"
|
||||
@@ -172,18 +234,6 @@ func cmdList() {
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
func sortedCopy(hosts []Host) []Host {
|
||||
out := make([]Host, len(hosts))
|
||||
copy(out, hosts)
|
||||
// Save() sorts on write, but list can run before the first save.
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && strings.ToLower(out[j-1].Name) > strings.ToLower(out[j].Name); j-- {
|
||||
out[j-1], out[j] = out[j], out[j-1]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cmdAdd(args []string) {
|
||||
force := false
|
||||
if len(args) > 0 && args[0] == "-f" {
|
||||
@@ -336,20 +386,168 @@ func cmdDelete(args []string) {
|
||||
})
|
||||
}
|
||||
|
||||
func cmdUpdate() {
|
||||
s, err := openStore(dbPath())
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
func cmdPortFwdAdd(args []string) {
|
||||
force := false
|
||||
if len(args) > 0 && args[0] == "-f" {
|
||||
force = true
|
||||
args = args[1:]
|
||||
}
|
||||
hosts, err := s.Load()
|
||||
s.Close()
|
||||
if len(args) < 2 || len(args) > 4 {
|
||||
fail("usage: ipadm -pa [-f] <hostname> <wan-port> [lan-port] [tcp|udp|both]")
|
||||
}
|
||||
name := args[0]
|
||||
wanPort, err := parsePort(args[1])
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
|
||||
content := renderDnsmasqConfig(hosts)
|
||||
if err := applyDnsmasqConfig(dnsmasqHostsPath(), content); err != nil {
|
||||
withPortFwdStore(func(hosts []Host, fwds []PortForward) ([]PortForward, string, error) {
|
||||
if findHost(hosts, name) < 0 {
|
||||
return nil, "", fmt.Errorf("Host %q ist nicht in der ipadm-Datenbank (erst mit `ipadm %s` oder `ipadm -a` anlegen)", name, name)
|
||||
}
|
||||
lanPort, proto, err := classifyPortFwdArgs(args[2:], wanPort)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validatePort("wan-port", wanPort); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validatePort("lan-port", lanPort); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
conflicts := findConflictingPortFwds(fwds, wanPort, proto, -1)
|
||||
if len(conflicts) > 0 && !force {
|
||||
return nil, "", fmt.Errorf("wan-port %d/%s kollidiert mit bestehendem Port-Forward (benutze -f zum Überschreiben)", wanPort, proto)
|
||||
}
|
||||
for i := len(conflicts) - 1; i >= 0; i-- {
|
||||
fwds = append(fwds[:conflicts[i]], fwds[conflicts[i]+1:]...)
|
||||
}
|
||||
|
||||
fwds = append(fwds, PortForward{WanPort: wanPort, Proto: proto, Host: name, LanPort: lanPort})
|
||||
return fwds, fmt.Sprintf("Port-Forward %d/%s -> %s:%d angelegt.", wanPort, proto, name, lanPort), nil
|
||||
})
|
||||
}
|
||||
|
||||
func cmdPortFwdList() {
|
||||
hs, err := openHostStore(dbPath())
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
hosts, err := hs.Load()
|
||||
hs.Close()
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
ipByHost := map[string]string{}
|
||||
for _, h := range hosts {
|
||||
ipByHost[strings.ToLower(h.Name)] = h.IP
|
||||
}
|
||||
|
||||
s, err := openPortFwdStore(portFwdDBPath())
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
defer s.Close()
|
||||
fwds, err := s.Load()
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
if len(fwds) == 0 {
|
||||
fmt.Println("keine Port-Forwards eingetragen")
|
||||
return
|
||||
}
|
||||
sortPortFwds(fwds)
|
||||
tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "WAN-PORT\tPROTO\tHOST\tLAN-IP\tLAN-PORT")
|
||||
for _, f := range fwds {
|
||||
ip, ok := ipByHost[strings.ToLower(f.Host)]
|
||||
if !ok {
|
||||
ip = "??? (Host fehlt)"
|
||||
}
|
||||
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\n", f.WanPort, f.Proto, f.Host, ip, f.LanPort)
|
||||
}
|
||||
tw.Flush()
|
||||
}
|
||||
|
||||
func cmdPortFwdDelete(args []string) {
|
||||
if len(args) < 1 || len(args) > 2 {
|
||||
fail("usage: ipadm -pd <wan-port> [tcp|udp|both]")
|
||||
}
|
||||
wanPort, err := parsePort(args[0])
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
proto := ""
|
||||
if len(args) == 2 {
|
||||
proto = args[1]
|
||||
if !validProtos[proto] {
|
||||
fail("ungültiges Protokoll %q (erwarte tcp/udp/both)", proto)
|
||||
}
|
||||
}
|
||||
|
||||
withPortFwdStore(func(hosts []Host, fwds []PortForward) ([]PortForward, string, error) {
|
||||
matches := findPortFwd(fwds, wanPort, proto)
|
||||
if len(matches) == 0 {
|
||||
return nil, "", fmt.Errorf("kein Port-Forward für wan-port %d gefunden", wanPort)
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
var protos []string
|
||||
for _, i := range matches {
|
||||
protos = append(protos, fwds[i].Proto)
|
||||
}
|
||||
return nil, "", fmt.Errorf("wan-port %d ist mehrdeutig (Protokolle: %s) — bitte Protokoll angeben", wanPort, strings.Join(protos, ", "))
|
||||
}
|
||||
idx := matches[0]
|
||||
deleted := fwds[idx]
|
||||
fwds = append(fwds[:idx], fwds[idx+1:]...)
|
||||
return fwds, fmt.Sprintf("Port-Forward %d/%s (-> %s:%d) gelöscht.", deleted.WanPort, deleted.Proto, deleted.Host, deleted.LanPort), nil
|
||||
})
|
||||
}
|
||||
|
||||
func parsePort(s string) (int, error) {
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(s, "%d", &port); err != nil {
|
||||
return 0, fmt.Errorf("ungültiger Port: %q", s)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func cmdUpdate() {
|
||||
cfg := loadNetConfig()
|
||||
|
||||
hs, err := openHostStore(dbPath())
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
hosts, err := hs.Load()
|
||||
hs.Close()
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
|
||||
dnsContent := renderDnsmasqConfig(hosts)
|
||||
if err := applyDnsmasqConfig(dnsmasqHostsPath(), dnsContent); err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
fmt.Printf("%s aktualisiert, dnsmasq neu geladen (%d Hosts).\n", dnsmasqHostsPath(), len(hosts))
|
||||
|
||||
fs, err := openPortFwdStore(portFwdDBPath())
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
fwds, err := fs.Load()
|
||||
fs.Close()
|
||||
if err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
|
||||
nftContent, warnings := renderPortForwards(hosts, fwds, cfg.wanIface)
|
||||
for _, w := range warnings {
|
||||
fmt.Fprintln(os.Stderr, "warnung:", w)
|
||||
}
|
||||
if err := applyPortForwards(nftPortFwdPath(), nftContent); err != nil {
|
||||
fail("%s", err)
|
||||
}
|
||||
fmt.Printf("%s aktualisiert, nftables neu geladen (%d Port-Forwards).\n", nftPortFwdPath(), len(fwds)-len(warnings))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// renderPortForwards builds the content of the generated nftables include
|
||||
// file (a standalone "inet portforward" table) from the current forwards.
|
||||
// Forwards whose target host no longer exists in the host DB are skipped
|
||||
// and reported back as warnings instead of failing the whole render.
|
||||
func renderPortForwards(hosts []Host, fwds []PortForward, wanIface string) (content string, warnings []string) {
|
||||
ipByHost := map[string]string{}
|
||||
for _, h := range hosts {
|
||||
ipByHost[strings.ToLower(h.Name)] = h.IP
|
||||
}
|
||||
|
||||
sorted := make([]PortForward, len(fwds))
|
||||
copy(sorted, fwds)
|
||||
sortPortFwds(sorted)
|
||||
|
||||
var rules strings.Builder
|
||||
for _, f := range sorted {
|
||||
ip, ok := ipByHost[strings.ToLower(f.Host)]
|
||||
if !ok {
|
||||
warnings = append(warnings, fmt.Sprintf("Port-Forward %d/%s -> %q übersprungen: Host nicht (mehr) in der ipadm-Datenbank", f.WanPort, f.Proto, f.Host))
|
||||
continue
|
||||
}
|
||||
switch f.Proto {
|
||||
case "tcp", "udp":
|
||||
fmt.Fprintf(&rules, "\t\tiifname %q %s dport %d dnat ip to %s:%d\n", wanIface, f.Proto, f.WanPort, ip, f.LanPort)
|
||||
case "both":
|
||||
fmt.Fprintf(&rules, "\t\tiifname %q meta l4proto { tcp, udp } th dport %d dnat ip to %s:%d\n", wanIface, f.WanPort, ip, f.LanPort)
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by ipadm -u — DO NOT EDIT MANUALLY.\n")
|
||||
b.WriteString("# Edit forwards with `ipadm -pa`/`ipadm -pd` and re-run `ipadm -u` to regenerate this file.\n\n")
|
||||
b.WriteString("table inet portforward {\n")
|
||||
b.WriteString("\tchain prerouting {\n")
|
||||
b.WriteString("\t\ttype nat hook prerouting priority dstnat;\n")
|
||||
b.WriteString(rules.String())
|
||||
b.WriteString("\t}\n")
|
||||
b.WriteString("}\n")
|
||||
return b.String(), warnings
|
||||
}
|
||||
|
||||
// applyPortForwards writes the generated table to path, validates the full
|
||||
// nftables ruleset, and on success reloads nftables. On validation failure
|
||||
// the previous file content is restored and the service is left untouched.
|
||||
func applyPortForwards(path string, content string) error {
|
||||
var backup []byte
|
||||
hadFile := false
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
backup = b
|
||||
hadFile = true
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("kann bestehende %s nicht lesen: %w", path, err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dirOf(path), 0755); err != nil {
|
||||
return fmt.Errorf("kann Verzeichnis für %s nicht anlegen: %w", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("kann %s nicht schreiben: %w", path, err)
|
||||
}
|
||||
|
||||
if err := runNftTest(); err != nil {
|
||||
if hadFile {
|
||||
os.WriteFile(path, backup, 0644)
|
||||
} else {
|
||||
os.Remove(path)
|
||||
}
|
||||
return fmt.Errorf("nftables-Konfiguration ungültig, Änderung zurückgerollt: %w", err)
|
||||
}
|
||||
|
||||
if err := reloadNftables(); err != nil {
|
||||
return fmt.Errorf("%s geschrieben, aber Reload fehlgeschlagen: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNftTest() error {
|
||||
out, err := exec.Command("nft", "-c", "-f", "/etc/nftables.conf").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadNftables() error {
|
||||
out, err := exec.Command("systemctl", "reload", "nftables").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PortForward maps a WAN port/protocol to a port on a host that must exist
|
||||
// in the ipadm host database (referenced by name, so the forward keeps
|
||||
// working if the host's IP changes later).
|
||||
type PortForward struct {
|
||||
WanPort int
|
||||
Proto string // "tcp", "udp", or "both"
|
||||
Host string
|
||||
LanPort int
|
||||
}
|
||||
|
||||
var validProtos = map[string]bool{"tcp": true, "udp": true, "both": true}
|
||||
|
||||
const portFwdHeader = "# ipadm port-forward database - managed with `ipadm`, do not edit while ipadm is running\n" +
|
||||
"# wanport\tproto\thost\tlanport\n"
|
||||
|
||||
// One line per forward, tab-separated: wanport\tproto\thost\tlanport
|
||||
func parsePortFwdLine(line string) (PortForward, error) {
|
||||
parts := strings.Split(line, "\t")
|
||||
if len(parts) != 4 {
|
||||
return PortForward{}, fmt.Errorf("ungültige Zeile (erwarte 4 Tab-getrennte Felder): %q", line)
|
||||
}
|
||||
wanPort, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return PortForward{}, fmt.Errorf("ungültiger wan-port: %q", parts[0])
|
||||
}
|
||||
lanPort, err := strconv.Atoi(parts[3])
|
||||
if err != nil {
|
||||
return PortForward{}, fmt.Errorf("ungültiger lan-port: %q", parts[3])
|
||||
}
|
||||
proto := parts[1]
|
||||
if !validProtos[proto] {
|
||||
return PortForward{}, fmt.Errorf("ungültiges Protokoll: %q", proto)
|
||||
}
|
||||
return PortForward{WanPort: wanPort, Proto: proto, Host: parts[2], LanPort: lanPort}, nil
|
||||
}
|
||||
|
||||
func formatPortFwdLine(p PortForward) string {
|
||||
return fmt.Sprintf("%d\t%s\t%s\t%d", p.WanPort, p.Proto, p.Host, p.LanPort)
|
||||
}
|
||||
|
||||
func openPortFwdStore(path string) (*LineStore[PortForward], error) {
|
||||
return openLineStore(path, portFwdHeader, parsePortFwdLine, formatPortFwdLine)
|
||||
}
|
||||
|
||||
func sortPortFwds(fwds []PortForward) {
|
||||
sort.Slice(fwds, func(i, j int) bool {
|
||||
if fwds[i].WanPort != fwds[j].WanPort {
|
||||
return fwds[i].WanPort < fwds[j].WanPort
|
||||
}
|
||||
return fwds[i].Proto < fwds[j].Proto
|
||||
})
|
||||
}
|
||||
|
||||
func validatePort(label string, port int) error {
|
||||
if port < 1 || port > 65535 {
|
||||
return fmt.Errorf("ungültiger %s: %d (erlaubt: 1-65535)", label, port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// protosOverlap reports whether two port-forward entries on the same WAN
|
||||
// port would both try to handle the same traffic (e.g. "tcp" and "both").
|
||||
func protosOverlap(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return a == "both" || b == "both"
|
||||
}
|
||||
|
||||
// findConflictingPortFwds returns the indexes of entries that occupy the
|
||||
// same (wanport, proto) as the given one, excluding exceptIdx.
|
||||
func findConflictingPortFwds(fwds []PortForward, wanPort int, proto string, exceptIdx int) []int {
|
||||
var out []int
|
||||
for i, f := range fwds {
|
||||
if i == exceptIdx {
|
||||
continue
|
||||
}
|
||||
if f.WanPort == wanPort && protosOverlap(f.Proto, proto) {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findPortFwd(fwds []PortForward, wanPort int, proto string) []int {
|
||||
var out []int
|
||||
for i, f := range fwds {
|
||||
if f.WanPort == wanPort && (proto == "" || f.Proto == proto) {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// classifyPortFwdArgs sorts the optional trailing arguments of `ipadm -pa`
|
||||
// (lan-port and/or protocol, in any order) into their fields. Returns an
|
||||
// error on unrecognized or duplicate tokens.
|
||||
func classifyPortFwdArgs(args []string, wanPort int) (lanPort int, proto string, err error) {
|
||||
lanPort = wanPort
|
||||
proto = "tcp"
|
||||
lanPortSet, protoSet := false, false
|
||||
for _, a := range args {
|
||||
if validProtos[a] {
|
||||
if protoSet {
|
||||
return 0, "", fmt.Errorf("Protokoll mehrfach angegeben: %q", a)
|
||||
}
|
||||
proto = a
|
||||
protoSet = true
|
||||
continue
|
||||
}
|
||||
if n, convErr := strconv.Atoi(a); convErr == nil {
|
||||
if lanPortSet {
|
||||
return 0, "", fmt.Errorf("lan-port mehrfach angegeben: %q", a)
|
||||
}
|
||||
lanPort = n
|
||||
lanPortSet = true
|
||||
continue
|
||||
}
|
||||
return 0, "", fmt.Errorf("unbekanntes Argument %q (erwarte lan-port oder tcp/udp/both)", a)
|
||||
}
|
||||
return lanPort, proto, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// LineStore gives locked read-modify-write access to a flat text file with
|
||||
// one record per line. Lines starting with '#' and blank lines are ignored.
|
||||
// parse/format convert between a line and a record of type T.
|
||||
type LineStore[T any] struct {
|
||||
path string
|
||||
file *os.File
|
||||
header string
|
||||
parse func(line string) (T, error)
|
||||
format func(v T) string
|
||||
}
|
||||
|
||||
func openLineStore[T any](path, header string, parse func(string) (T, error), format func(T) string) (*LineStore[T], error) {
|
||||
if err := os.MkdirAll(dirOf(path), 0755); err != nil {
|
||||
return nil, fmt.Errorf("kann Verzeichnis für %s nicht anlegen: %w", path, err)
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kann %s nicht öffnen: %w", path, err)
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("kann Lock auf %s nicht setzen: %w", path, err)
|
||||
}
|
||||
return &LineStore[T]{path: path, file: f, header: header, parse: parse, format: format}, nil
|
||||
}
|
||||
|
||||
func (s *LineStore[T]) Close() error {
|
||||
syscall.Flock(int(s.file.Fd()), syscall.LOCK_UN)
|
||||
return s.file.Close()
|
||||
}
|
||||
|
||||
func (s *LineStore[T]) Load() ([]T, error) {
|
||||
if _, err := s.file.Seek(0, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []T
|
||||
sc := bufio.NewScanner(s.file)
|
||||
lineNo := 0
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimRight(sc.Text(), "\r\n")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
v, err := s.parse(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: %w", s.path, lineNo, err)
|
||||
}
|
||||
items = append(items, v)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *LineStore[T]) Save(items []T) error {
|
||||
var b strings.Builder
|
||||
b.WriteString(s.header)
|
||||
for _, v := range items {
|
||||
b.WriteString(s.format(v))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if err := s.file.Truncate(0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.file.Seek(0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.file.WriteString(b.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.file.Sync()
|
||||
}
|
||||
|
||||
func dirOf(path string) string {
|
||||
i := strings.LastIndexByte(path, '/')
|
||||
if i <= 0 {
|
||||
return "."
|
||||
}
|
||||
return path[:i]
|
||||
}
|
||||
Reference in New Issue
Block a user