server: refuse unsigned releases and polluted reserved addresses

Self-update now verifies SHA256SUMS.sig (ed25519, relsign package) against
a public key baked into the binary; the private key exists only in the CI
secret store, so a compromised release host can withhold updates but not
inject one. CI signs on every server-v* tag and hard-fails without the
secret. Operators with their own pipeline override the key via
ECHOLOT_SELF_UPDATE_PUBKEY (mint a pair with release-sign -gen).

Startup also now proves 80/443 are actually free on the reserved
measurement addresses by asking the OS (throwaway bind), not the config -
CheckReserved could never see a stray process, and the adb-beacon receiver
on 0.0.0.0:443 was exactly that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 12:32:02 +02:00
co-authored by Claude Opus 5
parent 20cfecf566
commit a49bef5821
12 changed files with 472 additions and 29 deletions
+45
View File
@@ -0,0 +1,45 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package selftest
import (
"fmt"
"net"
)
// ReservedWebPortsFree verifies that nothing on this host — this process or any other — is
// listening on 80/443 of the reserved measurement addresses.
//
// config.CheckReserved keeps *our own* listeners off those ports, but our configuration is not
// the host: the adb-beacon receiver, a separate Python process wildcard-bound to 0.0.0.0:443,
// silently voided the IPv4 interception proof for as long as it ran, and nothing in this server's
// config could have seen it. Asking the OS is the only check that covers processes we did not
// start.
//
// The mechanism is a throwaway bind: if the bind succeeds the port was provably free (closed
// again immediately — nothing is served), and if it fails with EADDRINUSE something is listening
// there. Any other failure (address not assigned to this host, missing privilege) means the
// question could not be answered, which is reported separately rather than pretending either way.
func ReservedWebPortsFree(ips []net.IP) (occupied, unverifiable []string) {
return portsFree(ips, []string{"80", "443"})
}
func portsFree(ips []net.IP, ports []string) (occupied, unverifiable []string) {
for _, ip := range ips {
for _, port := range ports {
addr := net.JoinHostPort(ip.String(), port)
ln, err := net.Listen("tcp", addr)
if err == nil {
ln.Close()
continue
}
if isAddrInUse(err) {
occupied = append(occupied, addr)
} else {
unverifiable = append(unverifiable, fmt.Sprintf("%s (%v)", addr, err))
}
}
}
return occupied, unverifiable
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package selftest
import (
"net"
"strconv"
"strings"
"testing"
)
// The real check runs against ports 80/443, which a test cannot bind without privileges; the
// port list is what varies here, the mechanism is identical.
func TestOccupiedPortIsDetected(t *testing.T) {
// The stray-process scenario: someone else holds the port before we look.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port)
occupied, unverifiable := portsFree([]net.IP{net.ParseIP("127.0.0.1")}, []string{port})
if len(occupied) != 1 || !strings.HasSuffix(occupied[0], ":"+port) {
t.Fatalf("a listening port must be reported occupied, got occupied=%v unverifiable=%v",
occupied, unverifiable)
}
}
func TestFreePortPassesAndStaysFree(t *testing.T) {
// Find a port that is free by construction, then check it.
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := strconv.Itoa(probe.Addr().(*net.TCPAddr).Port)
probe.Close()
occupied, unverifiable := portsFree([]net.IP{net.ParseIP("127.0.0.1")}, []string{port})
if len(occupied) != 0 || len(unverifiable) != 0 {
t.Fatalf("a free port must pass silently, got occupied=%v unverifiable=%v",
occupied, unverifiable)
}
// The check must not keep the port: it proves the state and gets out of the way.
ln, err := net.Listen("tcp", "127.0.0.1:"+port)
if err != nil {
t.Fatalf("the check left the port unusable: %v", err)
}
ln.Close()
}
func TestUnassignedAddressIsUnverifiableNotOccupied(t *testing.T) {
// 192.0.2.0/24 is TEST-NET-1: never assigned to this host, so the bind fails with something
// other than EADDRINUSE. That is "could not answer", not "occupied" — conflating them would
// refuse startup over a typo in ECHOLOT_RESERVED_ADDRS.
occupied, unverifiable := portsFree([]net.IP{net.ParseIP("192.0.2.1")}, []string{"65001"})
if len(occupied) != 0 {
t.Fatalf("an unassigned address must not be reported occupied: %v", occupied)
}
if len(unverifiable) != 1 {
t.Fatalf("an unassigned address must be reported unverifiable, got %v", unverifiable)
}
}
+13
View File
@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build !windows
package selftest
import (
"errors"
"syscall"
)
func isAddrInUse(err error) bool { return errors.Is(err, syscall.EADDRINUSE) }
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build windows
package selftest
import (
"errors"
"syscall"
)
// Winsock reports a taken port as WSAEADDRINUSE (10048), which syscall.EADDRINUSE does not match
// on Windows — and the stdlib syscall package does not export the WSA constant. The server
// deploys on Linux; this exists so the tests tell the truth on a Windows development machine too.
const wsaeaddrinuse = syscall.Errno(10048)
func isAddrInUse(err error) bool {
return errors.Is(err, wsaeaddrinuse) || errors.Is(err, syscall.EADDRINUSE)
}