fix: address check pings with a raw socket and asks the DNS agents
On the user's server the check reported "socket: permission denied" for ping and asked only the server's own resolver. Ping now falls back to a raw ICMP socket (the server unit gets CAP_NET_RAW, like /bin/ping); DNS asks the DNS agents outside clusters when no name server addresses are configured, gives every server its own timeout, and explains missing records (dynamic zone, system resolver, not deployed yet). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3f8c9fc0fa
commit
b81adccac5
@@ -40,8 +40,10 @@ LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
# Only for the address check (ping): raw ICMP sockets where unprivileged
|
||||
# ICMP sockets are not permitted (ADR 0025), like /bin/ping.
|
||||
CapabilityBoundingSet=CAP_NET_RAW
|
||||
AmbientCapabilities=CAP_NET_RAW
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
+2
-2
@@ -229,9 +229,9 @@ Im Zustand `read_only` (Testzeitraum abgelaufen, Lizenz abgelaufen oder gesperrt
|
||||
|
||||
Die Prüfung einzelner Einträge (`POST /addresses/check`, ADR 0025) läuft vom Server aus:
|
||||
|
||||
- **Ping `unavailable`:** Der Server sendet ICMP-Echo über unprivilegierte Datagramm-Sockets, ohne Capability. Linux erlaubt sie nur den Gruppen in `net.ipv4.ping_group_range` (viele Distributionen: `1 0`, also niemand). Freigeben für die Gruppe des Dienstes, z. B. `sysctl -w net.ipv4.ping_group_range="0 2147483647"` und dauerhaft in `/etc/sysctl.d/`. „Nicht verfügbar“ sagt nichts über den Host aus.
|
||||
- **Ping `unavailable`:** Der Server versucht zuerst unprivilegierte ICMP-Sockets (`net.ipv4.ping_group_range`), dann einen Raw-Socket mit `CAP_NET_RAW` aus seiner systemd-Unit. „Nicht verfügbar“ heißt, beides ging nicht: Unit älter als v0.8.1 (`install.sh --upgrade`), die Capability in einem Container nicht vorhanden (z. B. LXC ohne `CAP_NET_RAW`) oder der Server läuft nicht unter Linux. Abhilfe ohne Capability: `sysctl -w net.ipv4.ping_group_range="0 2147483647"` und dauerhaft in `/etc/sysctl.d/`. „Nicht verfügbar“ sagt nichts über den Host aus.
|
||||
- **Ping `unreachable`:** keine Antwort innerhalb von zwei Versuchen à 1 s – Host aus oder ICMP auf dem Weg (Firewall) gesperrt.
|
||||
- **DNS `error`:** Gefragt werden die Mitglieder des DNS-Clusters der Zone (Primary zuerst), sonst die Nameserver-Adressen aus den Generator-Einstellungen, sonst der System-Resolver; der Server braucht 53/udp und 53/tcp zu ihnen. `mismatch` heißt, der Server antwortet anders als die Adresstabelle – meist ist die letzte Änderung noch nicht ausgerollt.
|
||||
- **DNS `error` oder `missing`:** Gefragt werden die Mitglieder des DNS-Clusters der Zone (Primary zuerst), sonst die Nameserver-Adressen aus den Generator-Einstellungen, bei Zonen ohne Cluster außerdem die DNS-Agents unter der Adresse ihrer letzten Verbindung, erst dann der Resolver des Servers („system“). Der Server braucht 53/udp und 53/tcp zu ihnen. Die Hinweise unter dem Ergebnis nennen die häufigsten Gründe für „fehlt“: Name in einer **dynamischen Zone** (dort landen Einträge aus der Adresstabelle nur in der Anfangsdatei – z. B. eine Active-Directory-Domäne), nur der Resolver des Servers wurde gefragt, oder die Änderung ist **noch nicht ausgerollt**. `mismatch` heißt, der DNS-Server antwortet anders als die Adresstabelle.
|
||||
|
||||
### 10.8 Weitere Hinweise
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Brief §10 verlangt `scripts/install.sh` für die Rollen `server`, `agent-dns` u
|
||||
### systemd
|
||||
|
||||
- Vorlagen `deploy/systemd/*.service.in` mit Platzhaltern (`@PRODUCT@`, `@DISPLAY_NAME@`, `@BIN_SERVER@`, `@BIN_AGENT_DNS@`, `@BIN_AGENT_DHCP@`, `@BINDIR@`, `@CONFIG_DIR@`, `@ENV_PREFIX@`). `build-release.sh` legt die gerenderten Units in die Tarballs (`@BINDIR@` = `/usr/local/bin`), Pakete rendern sie mit `/usr/bin`.
|
||||
- Hardening in allen Units: `NoNewPrivileges`, `ProtectSystem=strict` mit gezielten `ReadWritePaths`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`, `ProtectKernel*`, `ProtectControlGroups`, `ProtectClock`, `RestrictAddressFamilies`, `RestrictNamespaces`, `RestrictSUIDSGID`, `LockPersonality`, `MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, leere `CapabilityBoundingSet`, `UMask=0027`. Server: eigener Benutzer.
|
||||
- Hardening in allen Units: `NoNewPrivileges`, `ProtectSystem=strict` mit gezielten `ReadWritePaths`, `ProtectHome`, `PrivateTmp`, `PrivateDevices`, `ProtectKernel*`, `ProtectControlGroups`, `ProtectClock`, `RestrictAddressFamilies`, `RestrictNamespaces`, `RestrictSUIDSGID`, `LockPersonality`, `MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, leere `CapabilityBoundingSet` (Ausnahme seit ADR 0025: der Server hat `CAP_NET_RAW` für die Ping-Prüfung), `UMask=0027`. Server: eigener Benutzer.
|
||||
|
||||
### Release
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ Bisher (ARCHITECTURE §3.2, ADR 0014) bestimmen Pools (`dhcp_pools`) den DHCP-Be
|
||||
|
||||
### Erreichbarkeits- und DNS-Prüfung
|
||||
|
||||
- `POST /addresses/check` (Recht `ipam.read`, 1–100 Einträge, parallel, je Prüfung höchstens wenige Sekunden): **Ping** vom Server aus über unprivilegierte ICMP-Echo-Sockets (`net.ipv4.ping_group_range`, ohne Capability; nicht verfügbar → Status `unavailable`), **DNS** vorwärts (FQDN → IP) und rückwärts (IP → FQDN).
|
||||
- Gefragt werden die autoritativen Server der Zone: Mitgliedsadressen des DNS-Clusters bzw. die Adressen der Nameserver aus den Generator-Einstellungen; ohne solche Adressen der System-Resolver. Ergebnis je Richtung `ok`, `missing`, `mismatch` oder `error`, mit befragtem Server und Antworten.
|
||||
- `POST /addresses/check` (Recht `ipam.read`, 1–100 Einträge, parallel, je Prüfung höchstens wenige Sekunden): **Ping** vom Server aus, zuerst über unprivilegierte ICMP-Echo-Sockets (`net.ipv4.ping_group_range`), sind die nicht erlaubt (z. B. in LXC-Containern), über einen Raw-Socket; dafür hat die Server-Unit als einzige Capability `CAP_NET_RAW` (wie `/bin/ping`). Geht beides nicht → Status `unavailable`. **DNS** vorwärts (FQDN → IP) und rückwärts (IP → FQDN).
|
||||
- Gefragt werden die autoritativen Server der Zone: Mitgliedsadressen des DNS-Clusters bzw. die Adressen der Nameserver aus den Generator-Einstellungen; bei Zonen der Standardgruppe zusätzlich die DNS-Agents ohne Cluster (Adresse ihrer letzten Verbindung, Port 53); erst ohne all das der System-Resolver. Jeder Server hat eine eigene Frist von 2 s, damit ein falsch eingetragener Nameserver die anderen nicht verdrängt. Ergebnis je Richtung `ok`, `missing`, `mismatch` oder `error`, mit befragtem Server und Antworten, dazu Hinweise (`notes`): `dynamic_zone` (Name liegt in einer dynamischen Zone, die Einträge aus der Adresstabelle nur über die Anfangsdatei erhält), `system_resolver` (kein zuständiger Server bekannt), `not_deployed` (Eintrag oder sein Netz seit der letzten Revision geändert; abgegrenzt über Audit-IDs wie die Änderungsübersicht). Die Abfragen gehen direkt an den Server, ohne `/etc/hosts`.
|
||||
- Der Server prüft nur Adressen aus der Adresstabelle (kein freies Ziel); es gibt keinen periodischen Scan.
|
||||
|
||||
### Oberfläche
|
||||
|
||||
@@ -2600,7 +2600,7 @@ components:
|
||||
AddressCheck:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, ip, fqdn, ping]
|
||||
required: [id, ip, fqdn, ping, notes]
|
||||
properties:
|
||||
id: { type: string }
|
||||
ip: { type: string }
|
||||
@@ -2608,3 +2608,7 @@ components:
|
||||
ping: { $ref: "#/components/schemas/PingResult" }
|
||||
forward: { $ref: "#/components/schemas/DNSCheck" }
|
||||
reverse: { $ref: "#/components/schemas/DNSCheck" }
|
||||
notes:
|
||||
type: array
|
||||
description: "Why a DNS record may be missing: dynamic_zone (entries of the address table reach a dynamic zone only through its initial file), system_resolver (no authoritative server known), not_deployed (changed after the latest revision)."
|
||||
items: { type: string, enum: [dynamic_zone, system_resolver, not_deployed] }
|
||||
|
||||
@@ -516,3 +516,81 @@ func TestCheckSystemResolver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAgentFallbackAndNotes: without name server addresses, zones of
|
||||
// the default group are checked at the DNS agents (address of their last
|
||||
// connection); a failing name server is skipped; notes explain dynamic
|
||||
// zones, the system resolver and undeployed changes.
|
||||
func TestCheckAgentFallbackAndNotes(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var asked []string
|
||||
e := newEnvFull(t, nil, nil, func(o *service.Options) {
|
||||
o.Ping = func(context.Context, netip.Addr) service.PingResult {
|
||||
return service.PingResult{Status: service.PingUnreachable}
|
||||
}
|
||||
o.Resolver = func(server string) service.DNSResolver {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
asked = append(asked, server)
|
||||
if server == "192.0.2.53:53" { // configured, but down
|
||||
return &fakeResolver{server: server, fail: true}
|
||||
}
|
||||
return &fakeResolver{server: server,
|
||||
hosts: map[string][]string{"srv.lab.test.": {"203.0.113.5"}},
|
||||
ptrs: map[string][]string{"203.0.113.5": {"srv.lab.test."}}}
|
||||
}
|
||||
})
|
||||
ctx := context.Background()
|
||||
admin := e.adminClient()
|
||||
admin.configureDNS() // name server ns1.office.test with address 192.0.2.53
|
||||
agent := e.enroll(admin.createToken("dns").Secret, "dns")
|
||||
remote := netip.MustParseAddr("198.51.100.7")
|
||||
if _, err := e.svc.AgentHello(ctx, agent, service.HelloInput{Hostname: "ns-a", RemoteIP: &remote}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{"cidr": "203.0.113.0/24", "name": "Lab", "domain": "lab.test"})
|
||||
admin.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "dyn.lab.test", "mode": "dynamic",
|
||||
"allow_update": []string{"203.0.113.10/32"}})
|
||||
var a, d service.AddressDTO
|
||||
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "203.0.113.5", "type": "static", "hostname": "srv"}).json(t, &a)
|
||||
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "203.0.113.6", "type": "static", "hostname": "x"}).json(t, &d)
|
||||
var out struct {
|
||||
Items []service.AddressCheck `json:"items"`
|
||||
}
|
||||
admin.mustStatus(200, "POST", "/api/v1/addresses/check", map[string]any{"ids": []string{a.ID}}).json(t, &out)
|
||||
c := out.Items[0]
|
||||
if c.Forward == nil || c.Forward.Status != "ok" || c.Forward.Server != "198.51.100.7:53" ||
|
||||
c.Reverse == nil || c.Reverse.Status != "ok" {
|
||||
t.Errorf("check %+v %+v", c.Forward, c.Reverse)
|
||||
}
|
||||
if !slices.Equal(c.Notes, []string{service.CheckNoteNotDeployed}) {
|
||||
t.Errorf("notes before the first revision %v", c.Notes)
|
||||
}
|
||||
mu.Lock()
|
||||
if !slices.Contains(asked, "192.0.2.53:53") || slices.Contains(asked, "") {
|
||||
t.Errorf("asked %v", asked)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// After a revision the entry is deployed.
|
||||
admin.newRevision()
|
||||
admin.mustStatus(200, "POST", "/api/v1/addresses/check", map[string]any{"ids": []string{a.ID}}).json(t, &out)
|
||||
if len(out.Items[0].Notes) != 0 {
|
||||
t.Errorf("notes after the revision %v", out.Items[0].Notes)
|
||||
}
|
||||
|
||||
// A name in a dynamic zone (here an Active Directory domain) is noted:
|
||||
// entries of the address table reach it only through the initial file.
|
||||
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{"cidr": "198.18.0.0/24", "name": "AD", "domain": "ad.lab.test"})
|
||||
var dc service.AddressDTO
|
||||
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "198.18.0.5", "type": "static", "hostname": "dc1"}).json(t, &dc)
|
||||
admin.mustStatus(201, "POST", "/api/v1/zones", map[string]any{"name": "ad.lab.test", "mode": "dynamic",
|
||||
"allow_update": []string{"198.18.0.5/32"}})
|
||||
admin.mustStatus(200, "POST", "/api/v1/addresses/check", map[string]any{"ids": []string{dc.ID, d.ID}}).json(t, &out)
|
||||
if !slices.Contains(out.Items[0].Notes, service.CheckNoteDynamicZone) {
|
||||
t.Errorf("dynamic zone not noted: %v", out.Items[0].Notes)
|
||||
}
|
||||
if slices.Contains(out.Items[1].Notes, service.CheckNoteDynamicZone) {
|
||||
t.Errorf("managed zone noted as dynamic: %v", out.Items[1].Notes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ const (
|
||||
checkConcurrency = 16
|
||||
// checkTimeout bounds a whole request.
|
||||
checkTimeout = 10 * time.Second
|
||||
// dnsTimeout bounds one DNS lookup (all servers of a zone).
|
||||
dnsTimeout = 3 * time.Second
|
||||
// dnsTimeout bounds the lookup at one server; the next server of the
|
||||
// zone gets its own time (within checkTimeout).
|
||||
dnsTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// Ping statuses.
|
||||
@@ -70,8 +71,24 @@ type AddressCheck struct {
|
||||
Ping PingResult `json:"ping"`
|
||||
Forward *DNSCheck `json:"forward,omitempty"`
|
||||
Reverse *DNSCheck `json:"reverse,omitempty"`
|
||||
// Notes explain DNS results (codes, see the Check* constants).
|
||||
Notes []string `json:"notes"`
|
||||
}
|
||||
|
||||
// Notes of an address check: why a DNS record may be missing.
|
||||
const (
|
||||
// CheckNoteDynamicZone: the name or the reverse name is in a dynamic
|
||||
// zone, which gets entries from the address table only in its initial
|
||||
// file (ADR 0004, 0023).
|
||||
CheckNoteDynamicZone = "dynamic_zone"
|
||||
// CheckNoteSystemResolver: no authoritative server is known, the
|
||||
// server's own resolver was asked.
|
||||
CheckNoteSystemResolver = "system_resolver"
|
||||
// CheckNoteNotDeployed: the entry changed after the latest revision
|
||||
// (or there is none yet), so the DNS servers cannot know it yet.
|
||||
CheckNoteNotDeployed = "not_deployed"
|
||||
)
|
||||
|
||||
// DNSResolver looks names up; *net.Resolver implements it.
|
||||
type DNSResolver interface {
|
||||
LookupHost(ctx context.Context, host string) ([]string, error)
|
||||
@@ -127,6 +144,10 @@ func (s *Service) CheckAddresses(ctx context.Context, ids []string) ([]AddressCh
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
undeployed, err := s.undeployedChanges(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, checkTimeout)
|
||||
defer cancel()
|
||||
@@ -156,12 +177,21 @@ func (s *Service) CheckAddresses(ctx context.Context, ids []string) ([]AddressCh
|
||||
}()
|
||||
}
|
||||
for i, a := range addrs {
|
||||
out[i] = AddressCheck{ID: a.ID, IP: a.IP.String(), FQDN: a.FQDN}
|
||||
out[i] = AddressCheck{ID: a.ID, IP: a.IP.String(), FQDN: a.FQDN, Notes: []string{}}
|
||||
c := &out[i]
|
||||
run(func() { c.Ping = pingWithContext(ctx, ping, a.IP) })
|
||||
if !a.Type.PublishesDNS() || a.FQDN == "" {
|
||||
continue
|
||||
}
|
||||
if servers.inDynamicZone(a.FQDN) || servers.inDynamicZone(reverseName(a.IP)) {
|
||||
c.Notes = append(c.Notes, CheckNoteDynamicZone)
|
||||
}
|
||||
if len(servers.forName(a.FQDN)) == 0 {
|
||||
c.Notes = append(c.Notes, CheckNoteSystemResolver)
|
||||
}
|
||||
if undeployed.all || undeployed.ids[a.ID] || undeployed.ids[a.NetworkID] {
|
||||
c.Notes = append(c.Notes, CheckNoteNotDeployed)
|
||||
}
|
||||
run(func() { c.Forward = checkForward(ctx, resolver, servers.forName(a.FQDN), a.FQDN, a.IP) })
|
||||
run(func() { c.Reverse = checkReverse(ctx, resolver, servers.forName(reverseName(a.IP)), a.FQDN, a.IP) })
|
||||
}
|
||||
@@ -235,14 +265,14 @@ func lookup(ctx context.Context, resolver ResolverFunc, servers []string,
|
||||
if len(servers) == 0 {
|
||||
servers = []string{""}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, dnsTimeout)
|
||||
defer cancel()
|
||||
for _, srv := range servers {
|
||||
server = srv
|
||||
if srv == "" {
|
||||
server = systemServer
|
||||
}
|
||||
answers, err = q(ctx, resolver(srv))
|
||||
sctx, cancel := context.WithTimeout(ctx, dnsTimeout)
|
||||
answers, err = q(sctx, resolver(srv))
|
||||
cancel()
|
||||
if err == nil || isNotFound(err) || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
@@ -292,27 +322,45 @@ type zoneServers struct {
|
||||
// cluster).
|
||||
byZone map[string][]string
|
||||
fallback []string
|
||||
// dynamic holds the explicit zones in dynamic mode.
|
||||
dynamic map[string]bool
|
||||
}
|
||||
|
||||
// forName returns the servers of the zone with the longest suffix of name.
|
||||
func (z *zoneServers) forName(name string) []string {
|
||||
// zoneOf returns the explicit zone with the longest suffix of name ("" if
|
||||
// none).
|
||||
func (z *zoneServers) zoneOf(name string) string {
|
||||
name = strings.ToLower(strings.TrimSuffix(name, "."))
|
||||
for {
|
||||
if s, ok := z.byZone[name]; ok {
|
||||
return s
|
||||
if _, ok := z.byZone[name]; ok {
|
||||
return name
|
||||
}
|
||||
i := strings.IndexByte(name, '.')
|
||||
if i < 0 {
|
||||
return z.fallback
|
||||
return ""
|
||||
}
|
||||
name = name[i+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// forName returns the servers of the zone with the longest suffix of name.
|
||||
func (z *zoneServers) forName(name string) []string {
|
||||
if zone := z.zoneOf(name); zone != "" {
|
||||
return z.byZone[zone]
|
||||
}
|
||||
return z.fallback
|
||||
}
|
||||
|
||||
// inDynamicZone reports whether name belongs to an explicit dynamic zone.
|
||||
func (z *zoneServers) inDynamicZone(name string) bool {
|
||||
zone := z.zoneOf(name)
|
||||
return zone != "" && z.dynamic[zone]
|
||||
}
|
||||
|
||||
// dnsServers determines the authoritative servers per zone: the member
|
||||
// addresses of the zone's DNS cluster (primary first), else the name server
|
||||
// addresses of the cluster or the generator settings, else none (system
|
||||
// resolver).
|
||||
// addresses of the cluster or the generator settings; zones of the default
|
||||
// group also ask the DNS agents outside clusters (address of their last
|
||||
// connection). Without any, the system resolver answers.
|
||||
func (s *Service) dnsServers(ctx context.Context, tx *store.Tx) (*zoneServers, error) {
|
||||
settings, err := s.generatorSettings(ctx, tx)
|
||||
if err != nil {
|
||||
@@ -352,9 +400,31 @@ func (s *Service) dnsServers(ctx context.Context, tx *store.Tx) (*zoneServers, e
|
||||
}
|
||||
byCluster[c.ID] = addrs
|
||||
}
|
||||
// The default group is served by the DNS agents outside clusters; when
|
||||
// no name server addresses are configured they are asked directly at
|
||||
// the address of their last connection.
|
||||
agents, err := tx.ListAgents(ctx)
|
||||
if err != nil {
|
||||
return nil, translate(err)
|
||||
}
|
||||
clustered := map[string]bool{}
|
||||
for _, c := range clusters {
|
||||
for _, m := range c.Members {
|
||||
clustered[m.AgentID] = true
|
||||
}
|
||||
}
|
||||
defaultGroup := slices.Clone(settingsNS)
|
||||
for _, a := range agents {
|
||||
if a.Role != RoleDNS || a.RevokedAt != nil || clustered[a.ID] || a.RemoteAddress == nil {
|
||||
continue
|
||||
}
|
||||
if addr := net.JoinHostPort(a.RemoteAddress.Unmap().String(), "53"); !slices.Contains(defaultGroup, addr) {
|
||||
defaultGroup = append(defaultGroup, addr)
|
||||
}
|
||||
}
|
||||
serversOf := func(clusterID string) []string {
|
||||
if clusterID == "" {
|
||||
return settingsNS
|
||||
return defaultGroup
|
||||
}
|
||||
if a, ok := byCluster[clusterID]; ok {
|
||||
return a
|
||||
@@ -362,13 +432,15 @@ func (s *Service) dnsServers(ctx context.Context, tx *store.Tx) (*zoneServers, e
|
||||
return settingsNS
|
||||
}
|
||||
defaultCluster := deref(settings.DNS.DefaultClusterID)
|
||||
out := &zoneServers{byZone: map[string][]string{}, fallback: serversOf(defaultCluster)}
|
||||
out := &zoneServers{byZone: map[string][]string{}, fallback: serversOf(defaultCluster), dynamic: map[string]bool{}}
|
||||
for _, z := range zones {
|
||||
cluster := defaultCluster
|
||||
if z.ClusterID != nil {
|
||||
cluster = *z.ClusterID
|
||||
}
|
||||
out.byZone[strings.ToLower(z.Name)] = serversOf(cluster)
|
||||
name := strings.ToLower(z.Name)
|
||||
out.byZone[name] = serversOf(cluster)
|
||||
out.dynamic[name] = z.Mode == "dynamic"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -390,3 +462,37 @@ func nsAddresses(addrs []string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// undeployed lists what changed after the latest revision.
|
||||
type undeployed struct {
|
||||
// all: there is no revision yet.
|
||||
all bool
|
||||
// ids of addresses and networks with audit entries after the revision.
|
||||
ids map[string]bool
|
||||
}
|
||||
|
||||
// undeployedChanges finds addresses and networks changed after the latest
|
||||
// revision. Like PendingChanges it compares audit IDs, not timestamps, so
|
||||
// the clocks of server and database do not matter. Network entries cover
|
||||
// range operations and domain changes, which are audited per network.
|
||||
func (s *Service) undeployedChanges(ctx context.Context, tx *store.Tx) (*undeployed, error) {
|
||||
latest, err := tx.LatestRevision(ctx)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return &undeployed{all: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, translate(err)
|
||||
}
|
||||
entries, err := tx.ConfigChangesSince(ctx, store.ChangeFilter{ObjectTypes: []string{"address", "network"},
|
||||
AfterRevision: latest.Number, Since: latest.CreatedAt, Limit: 10000})
|
||||
if err != nil {
|
||||
return nil, translate(err)
|
||||
}
|
||||
out := &undeployed{ids: map[string]bool{}}
|
||||
for _, e := range entries {
|
||||
if e.ObjectID != nil {
|
||||
out.ids[*e.ObjectID] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
@@ -15,41 +18,93 @@ import (
|
||||
"golang.org/x/net/ipv6"
|
||||
)
|
||||
|
||||
// ICMP echo check (ADR 0025): unprivileged datagram sockets, which Linux
|
||||
// allows to the groups in net.ipv4.ping_group_range – no raw sockets, no
|
||||
// capability.
|
||||
// ICMP echo check (ADR 0025). First an unprivileged datagram socket, which
|
||||
// Linux allows to the groups in net.ipv4.ping_group_range; if that is not
|
||||
// permitted (e.g. in LXC containers or with a restrictive sysctl), a raw
|
||||
// socket, which needs CAP_NET_RAW (granted by the server's systemd unit,
|
||||
// like /bin/ping has it).
|
||||
const (
|
||||
pingAttempts = 2
|
||||
pingTimeout = time.Second
|
||||
)
|
||||
|
||||
// pingPayload marks the requests (and keeps them recognizable in captures).
|
||||
var pingPayload = []byte("ddi-suite address check")
|
||||
var pingPayload = []byte("ddi address check")
|
||||
|
||||
// pingSocket is an open ICMP socket and how to address and match on it.
|
||||
type pingSocket struct {
|
||||
conn *icmp.PacketConn
|
||||
raw bool // raw socket: the ID is ours and every ICMP packet arrives
|
||||
proto int
|
||||
request icmp.Type
|
||||
reply icmp.Type
|
||||
}
|
||||
|
||||
// openPingSocket tries the unprivileged socket, then the raw one.
|
||||
func openPingSocket(ip netip.Addr) (*pingSocket, error) {
|
||||
s := &pingSocket{proto: 1, request: ipv4.ICMPTypeEcho, reply: ipv4.ICMPTypeEchoReply}
|
||||
dgram, raw, local := "udp4", "ip4:icmp", "0.0.0.0"
|
||||
if ip.Is6() {
|
||||
s.proto, s.request, s.reply = 58, ipv6.ICMPTypeEchoRequest, ipv6.ICMPTypeEchoReply
|
||||
dgram, raw, local = "udp6", "ip6:ipv6-icmp", "::"
|
||||
}
|
||||
conn, err := icmp.ListenPacket(dgram, local)
|
||||
if err == nil {
|
||||
s.conn = conn
|
||||
return s, nil
|
||||
}
|
||||
conn, rawErr := icmp.ListenPacket(raw, local)
|
||||
if rawErr != nil {
|
||||
return nil, fmt.Errorf("ICMP not permitted for this process (datagram socket: %w; raw socket: %w)", err, rawErr)
|
||||
}
|
||||
s.conn, s.raw = conn, true
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// target is the destination address in the form the socket expects.
|
||||
func (s *pingSocket) target(ip netip.Addr) net.Addr {
|
||||
if s.raw {
|
||||
return &net.IPAddr{IP: ip.AsSlice(), Zone: ip.Zone()}
|
||||
}
|
||||
return &net.UDPAddr{IP: ip.AsSlice(), Zone: ip.Zone()}
|
||||
}
|
||||
|
||||
// peerAddr extracts the sender of a received packet.
|
||||
func peerAddr(a net.Addr) (netip.Addr, bool) {
|
||||
switch p := a.(type) {
|
||||
case *net.UDPAddr:
|
||||
ip, ok := netip.AddrFromSlice(p.IP)
|
||||
return ip.Unmap(), ok
|
||||
case *net.IPAddr:
|
||||
ip, ok := netip.AddrFromSlice(p.IP)
|
||||
return ip.Unmap(), ok
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// icmpPing sends up to pingAttempts echo requests to ip. If no socket can be
|
||||
// opened (ICMP sockets not permitted), the check is unavailable – which
|
||||
// says nothing about the host.
|
||||
// opened, the check is unavailable – which says nothing about the host.
|
||||
func icmpPing(ctx context.Context, ip netip.Addr) PingResult {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
return PingResult{Status: PingUnavailable, Error: "unprivileged ICMP echo is not supported on " + runtime.GOOS}
|
||||
return PingResult{Status: PingUnavailable, Error: "ICMP echo from the server is not supported on " + runtime.GOOS}
|
||||
}
|
||||
network, local, proto := "udp4", "0.0.0.0", 1
|
||||
var request, reply icmp.Type = ipv4.ICMPTypeEcho, ipv4.ICMPTypeEchoReply
|
||||
if ip.Is6() {
|
||||
network, local, proto = "udp6", "::", 58
|
||||
request, reply = ipv6.ICMPTypeEchoRequest, ipv6.ICMPTypeEchoReply
|
||||
}
|
||||
conn, err := icmp.ListenPacket(network, local)
|
||||
s, err := openPingSocket(ip)
|
||||
if err != nil {
|
||||
return PingResult{Status: PingUnavailable, Error: err.Error()}
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck // nothing to do on close errors of a datagram socket
|
||||
target := &net.UDPAddr{IP: ip.AsSlice(), Zone: ip.Zone()}
|
||||
defer s.conn.Close() //nolint:errcheck // nothing to do on close errors of an ICMP socket
|
||||
// A raw socket sees every echo reply of the host; a random ID tells ours
|
||||
// apart. On datagram sockets the kernel sets the ID itself.
|
||||
var idb [2]byte
|
||||
if _, err := rand.Read(idb[:]); err != nil {
|
||||
return PingResult{Status: PingUnavailable, Error: err.Error()}
|
||||
}
|
||||
id := int(binary.BigEndian.Uint16(idb[:]))
|
||||
want := ip.WithZone("")
|
||||
buf := make([]byte, 1500)
|
||||
var lastErr error
|
||||
for seq := 1; seq <= pingAttempts && ctx.Err() == nil; seq++ {
|
||||
// The kernel replaces the identifier with the socket's own.
|
||||
msg := icmp.Message{Type: request, Body: &icmp.Echo{ID: 1, Seq: seq, Data: pingPayload}}
|
||||
msg := icmp.Message{Type: s.request, Body: &icmp.Echo{ID: id, Seq: seq, Data: pingPayload}}
|
||||
b, err := msg.Marshal(nil)
|
||||
if err != nil {
|
||||
return PingResult{Status: PingUnavailable, Error: err.Error()}
|
||||
@@ -59,15 +114,15 @@ func icmpPing(ctx context.Context, ip netip.Addr) PingResult {
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
if err := s.conn.SetDeadline(deadline); err != nil {
|
||||
return PingResult{Status: PingUnavailable, Error: err.Error()}
|
||||
}
|
||||
if _, err := conn.WriteTo(b, target); err != nil {
|
||||
if _, err := s.conn.WriteTo(b, s.target(ip)); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
for {
|
||||
n, peer, err := conn.ReadFrom(buf)
|
||||
n, peer, err := s.conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if !errors.As(err, &ne) || !ne.Timeout() {
|
||||
@@ -75,18 +130,16 @@ func icmpPing(ctx context.Context, ip netip.Addr) PingResult {
|
||||
}
|
||||
break
|
||||
}
|
||||
m, err := icmp.ParseMessage(proto, buf[:n])
|
||||
if err != nil || m.Type != reply {
|
||||
m, err := icmp.ParseMessage(s.proto, buf[:n])
|
||||
if err != nil || m.Type != s.reply {
|
||||
continue
|
||||
}
|
||||
echo, ok := m.Body.(*icmp.Echo)
|
||||
if !ok || echo.Seq != seq {
|
||||
if !ok || echo.Seq != seq || (s.raw && echo.ID != id) {
|
||||
continue
|
||||
}
|
||||
if pa, ok := peer.(*net.UDPAddr); ok {
|
||||
if from, ok := netip.AddrFromSlice(pa.IP); ok && from.Unmap() != ip.WithZone("") {
|
||||
continue
|
||||
}
|
||||
if from, ok := peerAddr(peer); ok && from != want {
|
||||
continue
|
||||
}
|
||||
rtt := float64(time.Since(start).Microseconds()) / 1000
|
||||
return PingResult{Status: PingReachable, RTTMs: &rtt}
|
||||
|
||||
@@ -221,6 +221,14 @@ wait_dig2 "192.0.2.10" www.e2e.test A
|
||||
wait_dig2 "www.e2e.test." web.e2e.test CNAME
|
||||
wait_dig2 '"ddi e2e m5"' info.e2e.test TXT
|
||||
wait_dig2 "www.e2e.test." -x 192.0.2.10
|
||||
# Address check (ADR 0025): ICMP must be usable by the server and DNS must
|
||||
# be asked at the authoritative servers (the configured name server
|
||||
# 192.0.2.53 does not exist; the check falls back to the next server).
|
||||
read -r ping_status fwd_status rev_status <<<"$(api check 192.0.2.10)"
|
||||
[[ "$ping_status" != unavailable ]] || fail "address check: ping unavailable on the server"
|
||||
[[ "$fwd_status" == ok && "$rev_status" == ok ]] ||
|
||||
fail "address check: DNS forward=$fwd_status reverse=$rev_status"
|
||||
echo "ok address check: ping $ping_status, DNS forward $fwd_status, reverse $rev_status"
|
||||
expect "6" soa_serial
|
||||
serial2="$(dig2 e2e.test SOA | awk '{print $3}')"
|
||||
[[ "$serial2" == 6 ]] || fail "secondary SOA serial is $serial2"
|
||||
|
||||
@@ -117,6 +117,15 @@ case "$1" in
|
||||
session
|
||||
req GET "/addresses?q=$2" | jq -r --arg ip "$2" '.items[] | select(.ip == $ip) | .fqdn'
|
||||
;;
|
||||
check)
|
||||
# check <ip> prints "<ping status> <forward status> <reverse status>" of
|
||||
# the address check (ADR 0025) for the entry of <ip>.
|
||||
session
|
||||
id=$(req GET "/addresses?q=$2" | jq -r --arg ip "$2" '.items[] | select(.ip == $ip) | .id')
|
||||
req POST /addresses/check "{\"ids\":[\"$id\"]}" >/tmp/check.json
|
||||
jq -c '.items[0]' /tmp/check.json >&2
|
||||
jq -r '.items[0] | "\(.ping.status) \(.forward.status // "-") \(.reverse.status // "-")"' /tmp/check.json
|
||||
;;
|
||||
token)
|
||||
session
|
||||
req POST /enrollment-tokens "{\"role\":\"$2\",\"description\":\"e2e\"}" | jq -r .command
|
||||
|
||||
+12
-1
@@ -176,14 +176,25 @@ echo "ok Kea configuration contains the reservation"
|
||||
# Dynamic entries (the DHCP range) have DNS names (ADR 0025).
|
||||
dyn_fqdn="$(api fqdn 192.0.2.100)"
|
||||
expect "192.0.2.100" dig_short "$dyn_fqdn" A
|
||||
# Address check (ADR 0025): ICMP must be usable by the server and DNS must
|
||||
# be asked at the authoritative servers (the configured name server
|
||||
# 192.0.2.53 does not exist; the check falls back to the next server).
|
||||
read -r ping_status fwd_status rev_status <<<"$(api check 192.0.2.10)"
|
||||
[[ "$ping_status" != unavailable ]] || fail "address check: ping unavailable on the server"
|
||||
[[ "$fwd_status" == ok && "$rev_status" == ok ]] ||
|
||||
fail "address check: DNS forward=$fwd_status reverse=$rev_status"
|
||||
echo "ok address check: ping $ping_status, DNS forward $fwd_status, reverse $rev_status"
|
||||
|
||||
step "Hardening and service users"
|
||||
for unit in ddi-server ddi-agent-dns ddi-agent-dhcp; do
|
||||
props="$(ex systemctl show -p NoNewPrivileges -p ProtectSystem -p ProtectHome -p PrivateTmp \
|
||||
-p PrivateDevices -p RestrictSUIDSGID -p MemoryDenyWriteExecute -p CapabilityBoundingSet -p User "$unit")"
|
||||
echo "$unit: $(tr '\n' ' ' <<<"$props")"
|
||||
# Only the server may open raw ICMP sockets (address check, ADR 0025).
|
||||
caps="CapabilityBoundingSet="
|
||||
[[ "$unit" == ddi-server ]] && caps="CapabilityBoundingSet=cap_net_raw"
|
||||
for want in NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes PrivateDevices=yes \
|
||||
RestrictSUIDSGID=yes MemoryDenyWriteExecute=yes CapabilityBoundingSet=; do
|
||||
RestrictSUIDSGID=yes MemoryDenyWriteExecute=yes "$caps"; do
|
||||
grep -qx "$want" <<<"$props" || fail "$unit: $want not set"
|
||||
done
|
||||
pid="$(ex systemctl show -P MainPID "$unit")"
|
||||
|
||||
@@ -269,6 +269,7 @@ describe('network IP view', { timeout: 20_000 }, () => {
|
||||
ping: { status: 'reachable', rtt_ms: 0.52 },
|
||||
forward: { status: 'ok', server: '10.0.0.53:53', answers: ['10.0.0.10'] },
|
||||
reverse: { status: 'missing', server: '10.0.0.53:53', answers: [] },
|
||||
notes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -405,6 +406,7 @@ describe('addresses', () => {
|
||||
answers: ['10.0.0.99'],
|
||||
},
|
||||
reverse: { status: 'error', server: 'system', answers: [], error: 'timeout' },
|
||||
notes: ['system_resolver', 'not_deployed'],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -418,6 +420,8 @@ describe('addresses', () => {
|
||||
expect(await within(dialog).findByText('nicht möglich')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('abweichend')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Fehler')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/Kein zuständiger DNS-Server bekannt/)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/erst nach dem Ausrollen/)).toBeInTheDocument();
|
||||
expect(calls.find((c) => c.path === '/api/v1/addresses/check')?.body).toEqual({
|
||||
ids: ['a2'],
|
||||
});
|
||||
|
||||
+2
@@ -1894,6 +1894,8 @@ export interface components {
|
||||
ping: components["schemas"]["PingResult"];
|
||||
forward?: components["schemas"]["DNSCheck"];
|
||||
reverse?: components["schemas"]["DNSCheck"];
|
||||
/** @description Why a DNS record may be missing: dynamic_zone (entries of the address table reach a dynamic zone only through its initial file), system_resolver (no authoritative server known), not_deployed (changed after the latest revision). */
|
||||
notes: ("dynamic_zone" | "system_resolver" | "not_deployed")[];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
|
||||
@@ -386,6 +386,11 @@
|
||||
"missing": "fehlt",
|
||||
"mismatch": "abweichend",
|
||||
"error": "Fehler"
|
||||
},
|
||||
"notes": {
|
||||
"dynamic_zone": "Dynamische Zone: Einträge aus der Adresstabelle stehen dort nur in der Anfangsdatei; spätere Änderungen schreibt der Server nicht hinein.",
|
||||
"system_resolver": "Kein zuständiger DNS-Server bekannt (kein Cluster, keine Nameserver-Adressen, kein verbundener DNS-Agent) – gefragt wurde der Resolver des Servers.",
|
||||
"not_deployed": "Seit der letzten Revision geändert – erst nach dem Ausrollen im DNS."
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
|
||||
@@ -386,6 +386,11 @@
|
||||
"missing": "missing",
|
||||
"mismatch": "mismatch",
|
||||
"error": "error"
|
||||
},
|
||||
"notes": {
|
||||
"dynamic_zone": "Dynamic zone: entries of the address table only go into its initial file; the server does not write later changes into it.",
|
||||
"system_resolver": "No responsible DNS server known (no cluster, no name server addresses, no connected DNS agent) – the server's resolver was asked.",
|
||||
"not_deployed": "Changed after the latest revision – in DNS only after deploying."
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Fragment, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { maxCheck } from '../addressOps';
|
||||
@@ -101,19 +101,31 @@ export function CheckDialog({ ids, onClose }: { ids: string[]; onClose: () => vo
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.data.items.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className="mono">{c.ip}</td>
|
||||
<td>{c.fqdn || <span className="faint">{t('common.none')}</span>}</td>
|
||||
<td className="check-cell">
|
||||
<PingCell ping={c.ping} />
|
||||
</td>
|
||||
<td className="check-cell">
|
||||
<DNSCell dns={c.forward} />
|
||||
</td>
|
||||
<td className="check-cell">
|
||||
<DNSCell dns={c.reverse} />
|
||||
</td>
|
||||
</tr>
|
||||
<Fragment key={c.id}>
|
||||
<tr>
|
||||
<td className="mono">{c.ip}</td>
|
||||
<td>{c.fqdn || <span className="faint">{t('common.none')}</span>}</td>
|
||||
<td className="check-cell">
|
||||
<PingCell ping={c.ping} />
|
||||
</td>
|
||||
<td className="check-cell">
|
||||
<DNSCell dns={c.forward} />
|
||||
</td>
|
||||
<td className="check-cell">
|
||||
<DNSCell dns={c.reverse} />
|
||||
</td>
|
||||
</tr>
|
||||
{c.notes.length > 0 && (
|
||||
<tr className="check-notes">
|
||||
<td />
|
||||
<td colSpan={4} className="muted">
|
||||
{c.notes.map((n) => (
|
||||
<div key={n}>{t(`check.notes.${n}`)}</div>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user