diff --git a/control.go b/control.go index 7df5a09e..2b6b907e 100644 --- a/control.go +++ b/control.go @@ -52,6 +52,7 @@ type Control struct { sshStart func() statsStart func() dnsStart func() + infoAPIStart func() lighthouseStart func() networkChangeStart func(rebind func()) connectionManagerStart func(context.Context) @@ -108,6 +109,9 @@ func (c *Control) Start() error { if c.networkChangeStart != nil { go c.networkChangeStart(c.RebindUDPServer) } + if c.infoAPIStart != nil { + go c.infoAPIStart() + } if c.connectionManagerStart != nil { go c.connectionManagerStart(c.ctx) } diff --git a/dns_server.go b/dns_server.go index 9339b068..5ade90a8 100644 --- a/dns_server.go +++ b/dns_server.go @@ -258,31 +258,12 @@ func (d *dnsServer) QueryCert(data string) string { return "" } - // The hostmap only ever contains peers we have handshaked with, so it never carries an entry for ourselves. - // Answer self lookups straight from the local cert state. - if cs := d.certState(); cs != nil && cs.myVpnAddrsTable != nil && cs.myVpnAddrsTable.Contains(ip) { - c := cs.GetDefaultCertificate() - if c == nil { - return "" - } - b, err := c.MarshalJSON() - if err != nil { - return "" - } - return string(b) - } - - hostinfo := d.hostMap.QueryVpnAddr(ip) - if hostinfo == nil { + crt := findCertificateForVpnAddr(d.certState(), d.hostMap, ip) + if crt == nil { return "" } - q := hostinfo.GetCert() - if q == nil { - return "" - } - - b, err := q.Certificate.MarshalJSON() + b, err := crt.MarshalJSON() if err != nil { return "" } diff --git a/examples/config.yml b/examples/config.yml index d5409bce..d73c4b79 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -231,6 +231,35 @@ punchy: # Overriding this to "" is the same as "/" and will allow overwriting any path on the host. #sandbox_dir: /var/tmp/nebula-debug +# EXPERIMENTAL: this feature may change or disappear in the future. +# info_api exposes a small local HTTP+JSON API that lets other programs on +# this machine resolve a vpn address to its certificate identity (name, vpn +# addresses, groups, fingerprint, validity), e.g. for making authorization +# decisions about an inbound connection: +# GET /v1/host?addr= - identity of the host owning the address: a +# peer with an active tunnel, or this node itself. `addr` may include a +# port (`192.168.100.7:54321`), which is ignored, so a connection's remote +# address can be passed through as is. Returns 404 when the address is +# unknown or has no active tunnel. +# GET /v1/self - this node's own identity. +# Identity answers can be trusted because nebula drops inbound packets whose +# source vpn address is not contained in the sender's certificate, so the +# source address of a connection arriving over the nebula interface is +# guaranteed to map to the certificate reported here. +# There is no authentication in this API; restrict access with unix socket +# file permissions. +# This whole section is reloadable. +#info_api: + # Toggles the feature + #enabled: false + # listen accepts a unix socket path as a unix:// URL with an absolute path: + #listen: unix:///var/run/nebula-info-api.sock + # File mode for the unix socket, as an octal string. + # The socket is created by nebula's user; to grant a group of local services + # access, place the socket in a directory with appropriate permissions + # (e.g. a systemd RuntimeDirectory) and relax this to "0660". + #socket_mode: "0600" + # EXPERIMENTAL: relay support for networks that can't establish direct connections. relay: # Relays are a list of Nebula IP's that peers can use to relay packets to me. diff --git a/info_api_server.go b/info_api_server.go new file mode 100644 index 00000000..7f404e08 --- /dev/null +++ b/info_api_server.go @@ -0,0 +1,409 @@ +package nebula + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "net" + "net/http" + "net/netip" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/slackhq/nebula/cert" + "github.com/slackhq/nebula/config" +) + +// infoAPIServer is a small http+json listener on a unix socket that lets other +// programs on this machine resolve a vpn address to its certificate identity (name, groups, networks) +// for making authorization decisions. Lifecycle works like statsServer: the constructor wires the +// reload callback, reload records config, Start runs the runtime, Stop tears it down +type infoAPIServer struct { + l *slog.Logger + ctx context.Context + hostMap *HostMap + pki *PKI + + // enabled mirrors `info_api.enabled` so callers of Start don't need to know the gating rules + enabled atomic.Bool + + runMu sync.Mutex + runCfg *infoAPIConfig + run *infoAPIRuntime // non-nil while a runtime is live +} + +// infoAPIRuntime is the live state owned by a single Start invocation. Stop and Start's exit path +// use pointer equality to tell "my runtime" apart from one that replaced it after a reload +type infoAPIRuntime struct { + server *http.Server + listener net.Listener +} + +// infoAPIConfig is a snapshot of the info_api config section, comparable with == so reload can +// detect "no change" cheaply +type infoAPIConfig struct { + enabled bool + listen string // raw config value, for error messages + addr string // unix socket path + // file mode applied to the unix socket after bind + socketMode fs.FileMode +} + +// newInfoAPIServerFromConfig builds a infoAPIServer and applies the initial config. The reload +// callback is registered first so a SIGHUP can later enable, fix, or disable the listener even if +// the initial config was bad. Nothing binds until Start, so config tests are side effect free. +// A bad config is logged rather than returned: it must not stop nebula from starting, the feature +// just stays disabled until a reload provides a valid config +func newInfoAPIServerFromConfig(ctx context.Context, l *slog.Logger, pki *PKI, hostMap *HostMap, c *config.C) *infoAPIServer { + h := &infoAPIServer{ + l: l, + ctx: ctx, + hostMap: hostMap, + pki: pki, + } + + c.RegisterReloadCallback(func(c *config.C) { + if err := h.reload(c, false); err != nil { + h.l.Warn("Failed to reload info API from config", "error", err) + } + }) + + if err := h.reload(c, true); err != nil { + h.l.Warn("Failed to apply info API config; it will stay disabled until the config is fixed and reloaded", "error", err) + } + return h +} + +// reload records the latest config. The initial call only records it, Control.Start launches the +// first runtime via infoAPIStart. Later calls reconcile the running listener with the new config: +// enable, disable, or restart when the listen config changed +func (h *infoAPIServer) reload(c *config.C, initial bool) error { + newCfg, err := loadInfoAPIConfig(c) + if err != nil { + return err + } + + h.runMu.Lock() + sameCfg := h.runCfg != nil && *h.runCfg == newCfg + h.runCfg = &newCfg + running := h.run != nil + h.runMu.Unlock() + + h.enabled.Store(newCfg.enabled) + + if initial || sameCfg { + return nil + } + + if running { + h.Stop() + } + if newCfg.enabled { + go h.Start() + } + return nil +} + +// Start binds the listener from the latest config and serves until Stop is called or ctx fires. +// Safe to call when disabled or already running (both no-op) +func (h *infoAPIServer) Start() { + if !h.enabled.Load() { + return + } + + h.runMu.Lock() + if h.ctx.Err() != nil || h.run != nil || h.runCfg == nil { + h.runMu.Unlock() + return + } + cfg := *h.runCfg + ln, err := h.listen(cfg) + if err != nil { + // drop the cached config so a SIGHUP with the same config retries the bind + h.runCfg = nil + h.runMu.Unlock() + h.l.Error("Failed to start info API listener", "listen", cfg.listen, "error", err) + return + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/host", h.handleHost) + mux.HandleFunc("GET /v1/self", h.handleSelf) + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + rt := &infoAPIRuntime{server: srv, listener: ln} + h.run = rt + h.runMu.Unlock() + + h.l.Info("Starting info API listener", "addr", ln.Addr()) + cleanExit := h.serve(srv, ln) + + // A Stop that raced our bind shut the server down before Serve could adopt the listener; + // closing it again is harmless and guarantees a unix socket file gets unlinked + _ = ln.Close() + + // Clear our runtime only if nothing has replaced it. Stop races through here too but leaves + // h.run == nil, so the pointer check skips + h.runMu.Lock() + if h.run == rt { + h.run = nil + // an error exit leaves runCfg cached as if it were applied, drop it so a SIGHUP with the + // same config re-triggers Start once the user fixes the underlying problem + if !cleanExit { + h.runCfg = nil + } + } + h.runMu.Unlock() +} + +// serve runs srv.Serve and ensures ctx cancellation unblocks it. Returns true if the listener +// exited cleanly (Stop, ctx cancellation), false on an unexpected error +func (h *infoAPIServer) serve(srv *http.Server, ln net.Listener) bool { + // ctx cancellation triggers a server shutdown which in turn unblocks Serve, closing `done` on + // exit keeps the watcher from outliving this call + done := make(chan struct{}) + go func() { + select { + case <-h.ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + h.l.Warn("Failed to shut down info API listener", "error", err) + } + case <-done: + } + }() + defer close(done) + + err := srv.Serve(ln) + if err == nil || errors.Is(err, http.ErrServerClosed) { + return true + } + h.l.Error("Info API listener exited", "error", err) + return false +} + +// Stop tears down the active runtime, if any. Idempotent +func (h *infoAPIServer) Stop() { + h.runMu.Lock() + rt := h.run + h.run = nil + h.runMu.Unlock() + if rt == nil { + return + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := rt.server.Shutdown(shutdownCtx); err != nil { + h.l.Warn("Failed to shut down info API listener", "error", err) + } +} + +// listen binds the configured unix socket. It also clears a stale socket file left by an unclean +// exit and applies the configured file mode +func (h *infoAPIServer) listen(cfg infoAPIConfig) (net.Listener, error) { + if fi, err := os.Stat(cfg.addr); err == nil { + if fi.Mode()&os.ModeSocket == 0 { + return nil, fmt.Errorf("info_api.listen path %s exists and is not a socket, refusing to replace it", cfg.addr) + } + // a normal shutdown unlinks the socket, so a file here means a previous process exited + // uncleanly, remove it so the bind below can succeed + if err = os.Remove(cfg.addr); err != nil { + return nil, fmt.Errorf("failed to remove stale socket %s: %w", cfg.addr, err) + } + } + + ln, err := net.Listen("unix", cfg.addr) + if err != nil { + return nil, err + } + // The socket is briefly live with umask-derived permissions before this chmod lands, tolerated + // because connections accepted in that window still only reach this read-only API + if err = os.Chmod(cfg.addr, cfg.socketMode); err != nil { + _ = ln.Close() + return nil, fmt.Errorf("failed to set mode on socket %s: %w", cfg.addr, err) + } + return ln, nil +} + +func (h *infoAPIServer) certState() *CertState { + if h.pki == nil { + return nil + } + return h.pki.getCertState() +} + +// handleHost serves GET /v1/host?addr=, answering with the identity of the host that +// owns the address: a peer with an active tunnel, or this node itself. addr may include a port, +// which is ignored, so clients can pass a connection's remote address through without parsing it +func (h *infoAPIServer) handleHost(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("addr") + if q == "" { + writeJSONError(w, http.StatusBadRequest, "missing addr parameter") + return + } + ip, err := parseQueryAddrParam(q) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid address") + return + } + + crt := findCertificateForVpnAddr(h.certState(), h.hostMap, ip) + if crt == nil { + writeJSONError(w, http.StatusNotFound, "no active tunnel for address") + return + } + h.writeHostIdentity(w, crt) +} + +// handleSelf serves GET /v1/self, answering with this node's own identity +func (h *infoAPIServer) handleSelf(w http.ResponseWriter, r *http.Request) { + var crt cert.Certificate + if cs := h.certState(); cs != nil { + crt = cs.getCertificate(cs.initiatingVersion) + } + if crt == nil { + writeJSONError(w, http.StatusInternalServerError, "no certificate available") + return + } + h.writeHostIdentity(w, crt) +} + +func (h *infoAPIServer) writeHostIdentity(w http.ResponseWriter, crt cert.Certificate) { + id, err := newHostIdentity(crt) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to fingerprint certificate") + return + } + w.Header().Set("Content-Type", "application/json") + if err = json.NewEncoder(w).Encode(id); err != nil { + h.l.Debug("Failed to write info API response", "error", err) + } +} + +// findCertificateForVpnAddr answers "who owns this vpn address": ourselves (from local cert state, +// the hostmap never carries an entry for this node) or a peer with an active tunnel. Returns nil +// when the address is unknown or the tunnel is mid-teardown +func findCertificateForVpnAddr(cs *CertState, hostMap *HostMap, ip netip.Addr) cert.Certificate { + if cs != nil && cs.myVpnAddrsTable != nil && cs.myVpnAddrsTable.Contains(ip) { + return cs.getCertificate(cs.initiatingVersion) + } + + hostinfo := hostMap.QueryVpnAddr(ip) + if hostinfo == nil { + return nil + } + cc := hostinfo.GetCert() + if cc == nil { + return nil + } + return cc.Certificate +} + +// hostIdentity is the json document served for both /v1/host and /v1/self, every field is derived +// from the authenticated certificate alone +type hostIdentity struct { + Name string `json:"name"` + VpnAddrs []netip.Addr `json:"vpnAddrs"` + Networks []netip.Prefix `json:"networks"` + UnsafeNetworks []netip.Prefix `json:"unsafeNetworks"` + Groups []string `json:"groups"` + Fingerprint string `json:"fingerprint"` + Issuer string `json:"issuer"` + NotBefore time.Time `json:"notBefore"` + NotAfter time.Time `json:"notAfter"` + CertVersion int `json:"certVersion"` +} + +func newHostIdentity(crt cert.Certificate) (hostIdentity, error) { + fp, err := crt.Fingerprint() + if err != nil { + return hostIdentity{}, err + } + + // slices are always allocated so they marshal as [] rather than null + networks := crt.Networks() + id := hostIdentity{ + Name: crt.Name(), + VpnAddrs: make([]netip.Addr, 0, len(networks)), + Networks: append(make([]netip.Prefix, 0, len(networks)), networks...), + UnsafeNetworks: append(make([]netip.Prefix, 0, len(crt.UnsafeNetworks())), crt.UnsafeNetworks()...), + Groups: append(make([]string, 0, len(crt.Groups())), crt.Groups()...), + Fingerprint: fp, + Issuer: crt.Issuer(), + NotBefore: crt.NotBefore(), + NotAfter: crt.NotAfter(), + CertVersion: int(crt.Version()), + } + for _, n := range networks { + id.VpnAddrs = append(id.VpnAddrs, n.Addr()) + } + return id, nil +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// parseQueryAddrParam parses the addr query parameter, accepting a bare address or an address with +// a port (`192.168.100.7:54321`, `[fd00::1]:443`) so callers can pass a connection's RemoteAddr +// straight through. The result is unmapped, 4in6 addresses (::ffff:a.b.c.d) become ipv4 +func parseQueryAddrParam(s string) (netip.Addr, error) { + if ip, err := netip.ParseAddr(s); err == nil { + return ip.Unmap(), nil + } + ap, err := netip.ParseAddrPort(s) + if err != nil { + return netip.Addr{}, err + } + return ap.Addr().Unmap(), nil +} + +func loadInfoAPIConfig(c *config.C) (infoAPIConfig, error) { + cfg := infoAPIConfig{ + enabled: c.GetBool("info_api.enabled", false), + listen: c.GetString("info_api.listen", ""), + } + if !cfg.enabled { + return cfg, nil + } + + if cfg.listen == "" { + return cfg, errors.New("info_api.listen can not be empty when info_api is enabled") + } + addr, err := parseInfoAPIListen(cfg.listen) + if err != nil { + return cfg, err + } + cfg.addr = addr + + // read as a string so yaml can't reinterpret the octal literal + modeStr := c.GetString("info_api.socket_mode", "0600") + mode, err := strconv.ParseUint(modeStr, 8, 32) + if err != nil || fs.FileMode(mode)&^fs.ModePerm != 0 { + return cfg, fmt.Errorf("info_api.socket_mode was not a valid octal file mode: %s", modeStr) + } + cfg.socketMode = fs.FileMode(mode) + return cfg, nil +} + +// parseInfoAPIListen extracts the unix socket path from the info_api.listen config value, which +// must be a `unix://` URL with an absolute path, e.g. `unix:///var/run/nebula.sock` +func parseInfoAPIListen(listen string) (addr string, err error) { + path, ok := strings.CutPrefix(listen, "unix://") + if !ok { + return "", fmt.Errorf("info_api.listen must be a unix:// socket path: %s", listen) + } else if !filepath.IsAbs(path) { + return "", fmt.Errorf("info_api.listen unix socket path must be absolute: %s", listen) + } + return path, nil +} diff --git a/info_api_server_test.go b/info_api_server_test.go new file mode 100644 index 00000000..e4d20312 --- /dev/null +++ b/info_api_server_test.go @@ -0,0 +1,448 @@ +package nebula + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/slackhq/nebula/cert" + "github.com/slackhq/nebula/cert_test" + "github.com/slackhq/nebula/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_parseInfoAPIListen(t *testing.T) { + type testCase struct { + listen string + addr string + wantErr bool + } + tests := []testCase{ + {listen: "", wantErr: true}, + {listen: "unix://", wantErr: true}, + {listen: "unix://relative/path.sock", wantErr: true}, + {listen: "not an address", wantErr: true}, + // tcp host:port addresses are no longer accepted + {listen: "127.0.0.1:8085", wantErr: true}, + {listen: "[::1]:8085", wantErr: true}, + {listen: "localhost:8085", wantErr: true}, + } + + // A unix socket path must be absolute for the OS that will bind it, and filepath.IsAbs is + // GOOS-specific. CI runs the suite separately on each OS, so assert the platform's own native + // absolute path is accepted while the other platform's is rejected. + posixPath := "unix:///var/run/nebula.sock" + winPath := `unix://C:\nebula\hq.sock` + if runtime.GOOS == "windows" { + tests = append(tests, + testCase{listen: winPath, addr: `C:\nebula\hq.sock`}, + testCase{listen: posixPath, wantErr: true}, + ) + } else { + tests = append(tests, + testCase{listen: posixPath, addr: "/var/run/nebula.sock"}, + testCase{listen: winPath, wantErr: true}, + ) + } + + for _, tt := range tests { + addr, err := parseInfoAPIListen(tt.listen) + if tt.wantErr { + require.Error(t, err, "listen=%q", tt.listen) + continue + } + require.NoError(t, err, "listen=%q", tt.listen) + assert.Equal(t, tt.addr, addr, "listen=%q", tt.listen) + } +} + +func Test_loadInfoAPIConfig(t *testing.T) { + c := config.NewC(nil) + + // the listen path must be absolute for the OS running the test (CI is per-OS) + listen, wantAddr := "unix:///tmp/hq.sock", "/tmp/hq.sock" + if runtime.GOOS == "windows" { + listen, wantAddr = `unix://C:\tmp\hq.sock`, `C:\tmp\hq.sock` + } + + // absent section means disabled, no error + cfg, err := loadInfoAPIConfig(c) + require.NoError(t, err) + assert.False(t, cfg.enabled) + + // enabled without a listen address is an error + setInfoAPIConfig(c, true, "", "") + _, err = loadInfoAPIConfig(c) + require.Error(t, err) + + // a unix socket gets the default mode + setInfoAPIConfig(c, true, listen, "") + cfg, err = loadInfoAPIConfig(c) + require.NoError(t, err) + assert.Equal(t, wantAddr, cfg.addr) + assert.Equal(t, fs.FileMode(0o600), cfg.socketMode) + + setInfoAPIConfig(c, true, listen, "0660") + cfg, err = loadInfoAPIConfig(c) + require.NoError(t, err) + assert.Equal(t, fs.FileMode(0o660), cfg.socketMode) + + setInfoAPIConfig(c, true, listen, "withers") + _, err = loadInfoAPIConfig(c) + require.Error(t, err) + + // mode bits beyond the permission bits are rejected + setInfoAPIConfig(c, true, listen, "10600") + _, err = loadInfoAPIConfig(c) + require.Error(t, err) + + // tcp host:port listen addresses are no longer supported + setInfoAPIConfig(c, true, "127.0.0.1:8085", "") + _, err = loadInfoAPIConfig(c) + require.Error(t, err) +} + +func TestInfoAPIServer_badConfigIsNonFatal(t *testing.T) { + // an enabled-but-invalid config must not stop construction; nebula keeps starting and the + // feature simply stays disabled until a reload supplies a valid config + c := config.NewC(nil) + setInfoAPIConfig(c, true, "not-a-unix-socket", "") + h := newInfoAPIServerFromConfig(context.Background(), slog.New(slog.DiscardHandler), nil, newHostMap(slog.New(slog.DiscardHandler)), c) + require.NotNil(t, h) + assert.False(t, h.enabled.Load()) + + // no config was recorded, so Start has nothing to bind and is a no-op + h.runMu.Lock() + assert.Nil(t, h.runCfg) + h.runMu.Unlock() + h.Start() + h.runMu.Lock() + assert.Nil(t, h.run) + h.runMu.Unlock() +} + +func setInfoAPIConfig(c *config.C, enabled bool, listen, socketMode string) { + settings := map[string]any{ + "enabled": enabled, + "listen": listen, + } + if socketMode != "" { + settings["socket_mode"] = socketMode + } + c.Settings["info_api"] = settings +} + +func newTestInfoAPIServer(t *testing.T) (*infoAPIServer, *config.C) { + t.Helper() + h := &infoAPIServer{ + l: slog.New(slog.DiscardHandler), + ctx: context.Background(), + hostMap: newHostMap(slog.New(slog.DiscardHandler)), + } + h.hostMap.preferredRanges.Store(&[]netip.Prefix{}) + return h, config.NewC(nil) +} + +// addTestPeer creates a certificate for a peer owning each addr (as a /24 or /64) and inserts it +// into the hostmap as an established tunnel +func addTestPeer(t *testing.T, hm *HostMap, name string, addrs []netip.Addr, unsafeNetworks []netip.Prefix, groups []string) cert.Certificate { + t.Helper() + networks := make([]netip.Prefix, 0, len(addrs)) + for _, a := range addrs { + bits := 24 + if a.Is6() { + bits = 64 + } + networks = append(networks, netip.PrefixFrom(a, bits)) + } + ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil) + crt, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca, caKey, name, time.Time{}, time.Time{}, networks, unsafeNetworks, groups) + fp, err := crt.Fingerprint() + require.NoError(t, err) + + hm.unlockedAddHostInfo(&HostInfo{ + ConnectionState: &ConnectionState{ + peerCert: &cert.CachedCertificate{Certificate: crt, Fingerprint: fp}, + }, + vpnAddrs: addrs, + relayState: RelayState{ + relayForByAddr: map[netip.Addr]*Relay{}, + relayForByIdx: map[uint32]*Relay{}, + }, + }, &Interface{}) + return crt +} + +func getHost(t *testing.T, h *infoAPIServer, addrParam string) (int, map[string]any) { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/v1/host?addr="+url.QueryEscape(addrParam), nil) + w := httptest.NewRecorder() + h.handleHost(w, r) + return decodeResponse(t, w) +} + +func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) (int, map[string]any) { + t.Helper() + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return w.Code, body +} + +func TestInfoAPIServer_handleHost(t *testing.T) { + h, _ := newTestInfoAPIServer(t) + h.pki = newTestPKI(t, "self", []netip.Addr{netip.MustParseAddr("10.0.0.1")}) + + peerV4 := netip.MustParseAddr("10.0.0.99") + peerV6 := netip.MustParseAddr("fd00::99") + addTestPeer(t, h.hostMap, "laptop-alice", []netip.Addr{peerV4, peerV6}, + []netip.Prefix{netip.MustParsePrefix("192.168.50.0/24")}, []string{"eng", "ssh"}) + addTestPeer(t, h.hostMap, "groupless", []netip.Addr{netip.MustParseAddr("10.0.0.77")}, nil, nil) + + // an established peer comes back with its full identity + code, body := getHost(t, h, "10.0.0.99") + require.Equal(t, http.StatusOK, code) + assert.Equal(t, "laptop-alice", body["name"]) + assert.Equal(t, []any{"10.0.0.99", "fd00::99"}, body["vpnAddrs"]) + assert.Equal(t, []any{"10.0.0.99/24", "fd00::99/64"}, body["networks"]) + assert.Equal(t, []any{"192.168.50.0/24"}, body["unsafeNetworks"]) + assert.Equal(t, []any{"eng", "ssh"}, body["groups"]) + assert.NotEmpty(t, body["fingerprint"]) + assert.Equal(t, "2", fmt.Sprintf("%v", body["certVersion"])) + assert.NotEmpty(t, body["notBefore"]) + assert.NotEmpty(t, body["notAfter"]) + + // empty cert slices marshal as [] rather than null + code, body = getHost(t, h, "10.0.0.77") + require.Equal(t, http.StatusOK, code) + require.NotNil(t, body["groups"]) + assert.Empty(t, body["groups"]) + require.NotNil(t, body["unsafeNetworks"]) + assert.Empty(t, body["unsafeNetworks"]) + + // a port in addr is ignored so RemoteAddr can be passed through directly, including the + // bracketed v6 and 4in6 forms + for _, q := range []string{"10.0.0.99:54321", "[fd00::99]:443", "::ffff:10.0.0.99"} { + code, body = getHost(t, h, q) + require.Equal(t, http.StatusOK, code, "addr=%q", q) + assert.Equal(t, "laptop-alice", body["name"], "addr=%q", q) + } + + // our own address answers from the local cert state + code, body = getHost(t, h, "10.0.0.1") + require.Equal(t, http.StatusOK, code) + assert.Equal(t, "self", body["name"]) + + code, body = getHost(t, h, "10.0.0.42") + assert.Equal(t, http.StatusNotFound, code) + assert.NotEmpty(t, body["error"]) + + // a tunnel mid-teardown (no peer cert) is treated as unknown + h.hostMap.unlockedAddHostInfo(&HostInfo{ + ConnectionState: &ConnectionState{}, + vpnAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.66")}, + relayState: RelayState{ + relayForByAddr: map[netip.Addr]*Relay{}, + relayForByIdx: map[uint32]*Relay{}, + }, + }, &Interface{}) + code, _ = getHost(t, h, "10.0.0.66") + assert.Equal(t, http.StatusNotFound, code) + + code, body = getHost(t, h, "not-an-address") + assert.Equal(t, http.StatusBadRequest, code) + assert.NotEmpty(t, body["error"]) + + r := httptest.NewRequest(http.MethodGet, "/v1/host", nil) + w := httptest.NewRecorder() + h.handleHost(w, r) + code, body = decodeResponse(t, w) + assert.Equal(t, http.StatusBadRequest, code) + assert.NotEmpty(t, body["error"]) +} + +func TestInfoAPIServer_handleSelf(t *testing.T) { + h, _ := newTestInfoAPIServer(t) + h.pki = newTestPKI(t, "lighthouse", []netip.Addr{netip.MustParseAddr("10.0.0.1")}) + + r := httptest.NewRequest(http.MethodGet, "/v1/self", nil) + w := httptest.NewRecorder() + h.handleSelf(w, r) + code, body := decodeResponse(t, w) + require.Equal(t, http.StatusOK, code) + assert.Equal(t, "lighthouse", body["name"]) + assert.Equal(t, []any{"10.0.0.1"}, body["vpnAddrs"]) + + // no cert state available should be an error, not a panic + h.pki = nil + w = httptest.NewRecorder() + h.handleSelf(w, r) + code, body = decodeResponse(t, w) + assert.Equal(t, http.StatusInternalServerError, code) + assert.NotEmpty(t, body["error"]) +} + +func unixHTTPClient(path string) *http.Client { + return &http.Client{ + Timeout: time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", path) + }, + }, + } +} + +// waitForServe polls until a GET /v1/self through client succeeds +func waitForServe(t *testing.T, client *http.Client) { + t.Helper() + waitFor(t, func() bool { + resp, err := client.Get("http://hostquery/v1/self") + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK + }) +} + +func skipIfNoUnixSockets(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("unix socket tests are not supported on windows CI") + } +} + +func TestInfoAPIServer_unixLifecycle(t *testing.T) { + skipIfNoUnixSockets(t) + h, c := newTestInfoAPIServer(t) + h.pki = newTestPKI(t, "self", []netip.Addr{netip.MustParseAddr("10.0.0.1")}) + + sock := filepath.Join(t.TempDir(), "hq.sock") + setInfoAPIConfig(c, true, "unix://"+sock, "") + require.NoError(t, h.reload(c, true)) + + done := make(chan struct{}) + go func() { + h.Start() + close(done) + }() + + client := unixHTTPClient(sock) + waitForServe(t, client) + + fi, err := os.Stat(sock) + require.NoError(t, err) + assert.Equal(t, fs.FileMode(0o600), fi.Mode().Perm()) + + resp, err := client.Get("http://hostquery/v1/host?addr=10.0.0.1") + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + h.Stop() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Start did not return after Stop") + } + _, err = os.Stat(sock) + assert.True(t, os.IsNotExist(err), "socket file should be unlinked on shutdown") +} + +func TestInfoAPIServer_staleSocket(t *testing.T) { + skipIfNoUnixSockets(t) + h, _ := newTestInfoAPIServer(t) + sock := filepath.Join(t.TempDir(), "hq.sock") + + // simulate an unclean exit, a leftover socket file with no listener + stale, err := net.ListenUnix("unix", &net.UnixAddr{Name: sock, Net: "unix"}) + require.NoError(t, err) + stale.SetUnlinkOnClose(false) + require.NoError(t, stale.Close()) + _, err = os.Stat(sock) + require.NoError(t, err, "stale socket file should exist") + + cfg := infoAPIConfig{addr: sock, socketMode: 0o600} + ln, err := h.listen(cfg) + require.NoError(t, err, "a stale socket should be removed and rebound") + require.NoError(t, ln.Close()) +} + +func TestInfoAPIServer_existingFileNotReplaced(t *testing.T) { + skipIfNoUnixSockets(t) + h, _ := newTestInfoAPIServer(t) + path := filepath.Join(t.TempDir(), "hq.sock") + require.NoError(t, os.WriteFile(path, []byte("precious"), 0o600)) + + cfg := infoAPIConfig{addr: path, socketMode: 0o600} + _, err := h.listen(cfg) + require.Error(t, err, "a non-socket file at the listen path must not be replaced") + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "precious", string(content)) +} + +func TestInfoAPIServer_reload(t *testing.T) { + skipIfNoUnixSockets(t) + h, c := newTestInfoAPIServer(t) + h.pki = newTestPKI(t, "self", []netip.Addr{netip.MustParseAddr("10.0.0.1")}) + dir := t.TempDir() + sock1 := filepath.Join(dir, "hq1.sock") + sock2 := filepath.Join(dir, "hq2.sock") + + // initial reload only records config, Control.Start is what launches the runtime + setInfoAPIConfig(c, false, "unix://"+sock1, "") + require.NoError(t, h.reload(c, true)) + assert.False(t, h.enabled.Load()) + h.runMu.Lock() + assert.Nil(t, h.run) + h.runMu.Unlock() + + // enabling via reload spawns the listener + setInfoAPIConfig(c, true, "unix://"+sock1, "") + require.NoError(t, h.reload(c, false)) + waitForServe(t, unixHTTPClient(sock1)) + + // changing the listen path restarts on the new address + setInfoAPIConfig(c, true, "unix://"+sock2, "") + require.NoError(t, h.reload(c, false)) + waitForServe(t, unixHTTPClient(sock2)) + waitFor(t, func() bool { + _, err := os.Stat(sock1) + return os.IsNotExist(err) + }) + + // reloading an unchanged config does not restart the runtime + h.runMu.Lock() + rt := h.run + h.runMu.Unlock() + require.NoError(t, h.reload(c, false)) + h.runMu.Lock() + assert.Same(t, rt, h.run) + h.runMu.Unlock() + + // disabling stops the listener + setInfoAPIConfig(c, false, "unix://"+sock2, "") + require.NoError(t, h.reload(c, false)) + assert.False(t, h.enabled.Load()) + waitFor(t, func() bool { + h.runMu.Lock() + defer h.runMu.Unlock() + return h.run == nil + }) +} diff --git a/main.go b/main.go index 2ef2031b..528e123c 100644 --- a/main.go +++ b/main.go @@ -260,6 +260,8 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev return nil, util.ContextualizeIfNeeded("Failed to start stats emitter", err) } + infoAPI := newInfoAPIServerFromConfig(ctx, l, pki, hostMap, c) + if configTest { return nil, nil } @@ -279,6 +281,7 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev sshStart: sshStart, statsStart: stats.Start, dnsStart: ds.Start, + infoAPIStart: infoAPI.Start, lighthouseStart: lightHouse.StartUpdateWorker, networkChangeStart: networkChanges.Start, connectionManagerStart: connManager.Start,