Files
abrown38andClaude Opus 5.5 34292eeebb Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:57:14 +01:00

131 lines
4.2 KiB
Go

package server
// The local unix socket (socket in housecarl.yaml, /run/housecarl/housecarl.sock by
// default). It serves the same API as the network listeners, but a request
// on it without an Authorization header is trusted by who sent it: the
// kernel reports the connecting process's user (SO_PEERCRED), and root and
// housecarld's own user get administrator rights without a token. Both can
// already do everything (read the database, run 'housecarld user', stop the
// service), so this adds convenience, not power: 'sudo hc status'
// works on the server with nothing configured, and a lost token or
// password never locks out root.
//
// Anyone else who can reach the socket must send an API token, which then
// gives that token's permissions, as on the network. The socket file is
// mode 0600 in a 0750 directory, so normally only root and the housecarl user
// can connect at all; the user check doesn't rely on that.
import (
"context"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"os/user"
"path/filepath"
"strconv"
"syscall"
"housecarl/internal/auth"
"housecarl/internal/model"
)
// peer is the process at the other end of a unix socket connection.
type peer struct {
UID int
PID int
}
type peerKey struct{}
// peerOf returns the socket peer of a request, or nil for network
// requests.
func peerOf(ctx context.Context) *peer {
p, _ := ctx.Value(peerKey{}).(*peer)
return p
}
// socketConnContext records the peer's credentials on each connection to
// the unix socket.
func socketConnContext(ctx context.Context, c net.Conn) context.Context {
uc, ok := c.(*net.UnixConn)
if !ok {
return ctx
}
p := &peer{UID: -1}
if raw, err := uc.SyscallConn(); err == nil {
raw.Control(func(fd uintptr) {
if cred, err := syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED); err == nil {
p.UID, p.PID = int(cred.Uid), int(cred.Pid)
}
})
}
// Even when the credentials can't be read, the request is marked as a
// socket request (with no trusted user).
return context.WithValue(ctx, peerKey{}, p)
}
// trustedUID reports whether a socket peer gets administrator rights
// without a token: root and the user housecarld runs as.
func trustedUID(uid int) bool {
return uid == 0 || uid == os.Getuid()
}
// socketPrincipal is the administrator for a trusted socket peer. It isn't
// a Housecarl user (ID 0), so it has no tokens or password; the audit log and
// history show it by its system user name, e.g. "root", with method
// "socket".
func socketPrincipal(p *peer, agent string) *auth.Principal {
name := "uid " + strconv.Itoa(p.UID)
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
name = u.Username
}
return &auth.Principal{User: &model.User{ID: 0, Username: name, Admin: true}, Method: "socket", IP: "local", Agent: agent}
}
// listenSocket creates the unix socket, replacing a stale one left by a
// previous run.
func listenSocket(path string) (net.Listener, error) {
if len(path) > 107 {
return nil, fmt.Errorf("the path is %d bytes long; unix sockets allow at most 107", len(path))
}
// Under systemd the directory is RuntimeDirectory=housecarl; elsewhere
// make it.
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, err
}
if fi, err := os.Lstat(path); err == nil {
if fi.Mode()&fs.ModeSocket == 0 {
return nil, fmt.Errorf("%s exists and isn't a socket", path)
}
// A socket nobody answers on is left over from a previous run.
if c, err := net.Dial("unix", path); err == nil {
c.Close()
return nil, fmt.Errorf("%s is in use by another housecarld", path)
}
os.Remove(path)
}
ln, err := net.Listen("unix", path)
if err != nil {
return nil, err
}
if err := os.Chmod(path, 0o600); err != nil {
ln.Close()
return nil, err
}
return ln, nil
}
// socketDenied explains a refused tokenless request on the socket.
func socketDenied(p *peer) error {
who := "an unknown user"
if p.UID >= 0 {
who = "uid " + strconv.Itoa(p.UID)
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
who = u.Username
}
}
return &httpError{http.StatusUnauthorized, "unauthenticated", "the local socket gives administrator rights without a token only to root and housecarld's own user, not to " + who + "; send an API token (--token), or run the command as root"}
}