mirror of
https://github.com/slackhq/nebula.git
synced 2026-08-15 18:57:00 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2df43dc218 | |||
| 72bf111209 |
@@ -52,6 +52,7 @@ type Control struct {
|
|||||||
sshStart func()
|
sshStart func()
|
||||||
statsStart func()
|
statsStart func()
|
||||||
dnsStart func()
|
dnsStart func()
|
||||||
|
infoAPIStart func()
|
||||||
lighthouseStart func()
|
lighthouseStart func()
|
||||||
networkChangeStart func(rebind func())
|
networkChangeStart func(rebind func())
|
||||||
connectionManagerStart func(context.Context)
|
connectionManagerStart func(context.Context)
|
||||||
@@ -108,6 +109,9 @@ func (c *Control) Start() error {
|
|||||||
if c.networkChangeStart != nil {
|
if c.networkChangeStart != nil {
|
||||||
go c.networkChangeStart(c.RebindUDPServer)
|
go c.networkChangeStart(c.RebindUDPServer)
|
||||||
}
|
}
|
||||||
|
if c.infoAPIStart != nil {
|
||||||
|
go c.infoAPIStart()
|
||||||
|
}
|
||||||
if c.connectionManagerStart != nil {
|
if c.connectionManagerStart != nil {
|
||||||
go c.connectionManagerStart(c.ctx)
|
go c.connectionManagerStart(c.ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-22
@@ -258,31 +258,12 @@ func (d *dnsServer) QueryCert(data string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// The hostmap only ever contains peers we have handshaked with, so it never carries an entry for ourselves.
|
crt := findCertificateForVpnAddr(d.certState(), d.hostMap, ip)
|
||||||
// Answer self lookups straight from the local cert state.
|
if crt == nil {
|
||||||
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 {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
q := hostinfo.GetCert()
|
b, err := crt.MarshalJSON()
|
||||||
if q == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := q.Certificate.MarshalJSON()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//go:build e2e_testing
|
||||||
|
// +build e2e_testing
|
||||||
|
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/slackhq/nebula"
|
||||||
|
"github.com/slackhq/nebula/cert"
|
||||||
|
"github.com/slackhq/nebula/cert_test"
|
||||||
|
"github.com/slackhq/nebula/e2e/router"
|
||||||
|
"github.com/slackhq/nebula/udp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRecoveryTiming measures how long a tunnel takes to come back after the peer stops accepting our traffic,
|
||||||
|
// which is what a laptop waking on a new network looks like from the peer's side: its NAT has no state for where
|
||||||
|
// we are now, so everything we send disappears.
|
||||||
|
//
|
||||||
|
// It is a measurement, not a pass/fail assertion. Recovery is timed to the moment the peer punches back at us,
|
||||||
|
// since that is when its NAT opens and the tunnel is usable again.
|
||||||
|
//
|
||||||
|
// go test -tags e2e_testing -v -run TestRecoveryTiming ./e2e/
|
||||||
|
func TestRecoveryTiming(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
rebind bool
|
||||||
|
}{
|
||||||
|
{"no trigger", false},
|
||||||
|
{"rebind counter", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
d, lost := measureRecovery(t, tc.rebind)
|
||||||
|
t.Logf("RESULT %-16s recovered in %-9v (%d packets lost)", tc.name, d.Round(time.Millisecond), lost)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureRecovery returns how long until the peer punched back, and how many of our packets died meanwhile. When
|
||||||
|
// rebind is true we call RebindUDPServer once the tunnel goes dark, which is what the darwin network change
|
||||||
|
// monitor does and what iOS has always done. When false, nothing tells nebula anything is wrong.
|
||||||
|
func measureRecovery(t *testing.T, rebind bool) (time.Duration, int) {
|
||||||
|
t.Helper()
|
||||||
|
ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{})
|
||||||
|
|
||||||
|
lhControl, lhVpnIpNet, lhUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "lh", "10.128.0.1/24", m{
|
||||||
|
"lighthouse": m{"am_lighthouse": true},
|
||||||
|
})
|
||||||
|
|
||||||
|
peerCfg := m{
|
||||||
|
"lighthouse": m{
|
||||||
|
"hosts": []any{lhVpnIpNet[0].Addr().String()},
|
||||||
|
"interval": 600,
|
||||||
|
"local_allow_list": m{
|
||||||
|
"10.0.0.0/24": true,
|
||||||
|
"::/0": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"static_host_map": m{
|
||||||
|
lhVpnIpNet[0].Addr().String(): []any{lhUdpAddr.String()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
myControl, myVpnIpNet, myUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.2/24", peerCfg)
|
||||||
|
theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.3/24", peerCfg)
|
||||||
|
|
||||||
|
r := router.NewR(t, lhControl, myControl, theirControl)
|
||||||
|
defer r.RenderFlow()
|
||||||
|
defer func() {
|
||||||
|
lhControl.Stop()
|
||||||
|
myControl.Stop()
|
||||||
|
theirControl.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
lhControl.Start()
|
||||||
|
myControl.Start()
|
||||||
|
theirControl.Start()
|
||||||
|
r.RouteFor(time.Millisecond * 500)
|
||||||
|
|
||||||
|
myControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr)
|
||||||
|
theirControl.InjectLightHouseAddr(myVpnIpNet[0].Addr(), myUdpAddr)
|
||||||
|
|
||||||
|
myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("establish")))
|
||||||
|
r.RouteFor(time.Second)
|
||||||
|
if myControl.GetHostInfoByVpnAddr(theirVpnIpNet[0].Addr(), false) == nil {
|
||||||
|
t.Fatal("failed to establish the tunnel we are measuring")
|
||||||
|
}
|
||||||
|
r.RouteFor(time.Millisecond * 500)
|
||||||
|
|
||||||
|
// From here the peer's NAT has no state for us, everything we send it disappears
|
||||||
|
start := time.Now()
|
||||||
|
blackholed := 0
|
||||||
|
var recovered time.Duration
|
||||||
|
|
||||||
|
if rebind {
|
||||||
|
myControl.RebindUDPServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the tun busy the way someone retrying a stalled connection would
|
||||||
|
stop := make(chan struct{})
|
||||||
|
defer close(stop)
|
||||||
|
go func() {
|
||||||
|
tick := time.NewTicker(time.Millisecond * 200)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
myControl.InjectTunPacket(BuildTunUDPPacket(
|
||||||
|
theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("retry")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
r.RouteForAllExitFuncOrTimeout(time.Second*30, func(p *udp.Packet, c *nebula.Control) router.ExitType {
|
||||||
|
if c == theirControl && p.From == myControl.GetUDPAddr() {
|
||||||
|
blackholed++
|
||||||
|
return router.Drop
|
||||||
|
}
|
||||||
|
|
||||||
|
// The peer reaching us directly is the moment its NAT opened, whether that is a punch or a handshake
|
||||||
|
if c == myControl && p.From == theirUdpAddr {
|
||||||
|
recovered = time.Since(start)
|
||||||
|
return router.RouteAndExit
|
||||||
|
}
|
||||||
|
|
||||||
|
return router.KeepRouting
|
||||||
|
})
|
||||||
|
|
||||||
|
if recovered == 0 {
|
||||||
|
t.Fatalf("no recovery within 30s (%d packets blackholed)", blackholed)
|
||||||
|
}
|
||||||
|
return recovered, blackholed
|
||||||
|
}
|
||||||
+19
-2
@@ -153,6 +153,9 @@ const (
|
|||||||
ExitNow ExitType = 1
|
ExitNow ExitType = 1
|
||||||
// RouteAndExit routes this packet and exits immediately afterwards
|
// RouteAndExit routes this packet and exits immediately afterwards
|
||||||
RouteAndExit ExitType = 2
|
RouteAndExit ExitType = 2
|
||||||
|
// Drop discards this packet without delivering it and keeps routing. Use it to simulate a blackhole, such as
|
||||||
|
// a restrictive NAT refusing traffic from an address it has not seen.
|
||||||
|
Drop ExitType = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExitFunc func(packet *udp.Packet, receiver *nebula.Control) ExitType
|
type ExitFunc func(packet *udp.Packet, receiver *nebula.Control) ExitType
|
||||||
@@ -163,7 +166,9 @@ type ExitFunc func(packet *udp.Packet, receiver *nebula.Control) ExitType
|
|||||||
func NewR(t testing.TB, controls ...*nebula.Control) *R {
|
func NewR(t testing.TB, controls ...*nebula.Control) *R {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
if err := os.MkdirAll("mermaid", 0755); err != nil {
|
// t.Name() contains a slash for subtests, so the flow log can land in a nested directory
|
||||||
|
fn := filepath.Join("mermaid", fmt.Sprintf("%s.md", t.Name()))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fn), 0755); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +179,7 @@ func NewR(t testing.TB, controls ...*nebula.Control) *R {
|
|||||||
outNat: make(map[outNatKey]netip.AddrPort),
|
outNat: make(map[outNatKey]netip.AddrPort),
|
||||||
flow: []flowEntry{},
|
flow: []flowEntry{},
|
||||||
ignoreFlows: []ignoreFlow{},
|
ignoreFlows: []ignoreFlow{},
|
||||||
fn: filepath.Join("mermaid", fmt.Sprintf("%s.md", t.Name())),
|
fn: fn,
|
||||||
t: t,
|
t: t,
|
||||||
cancelRender: cancel,
|
cancelRender: cancel,
|
||||||
}
|
}
|
||||||
@@ -687,6 +692,10 @@ func (r *R) RouteExitFunc(sender *nebula.Control, whatDo ExitFunc) {
|
|||||||
p.Release()
|
p.Release()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
case Drop:
|
||||||
|
// Record it so the flow log shows the attempt, but never hand it to the receiver
|
||||||
|
r.unlockedInjectFlow(sender, receiver, p, false)
|
||||||
|
|
||||||
case KeepRouting:
|
case KeepRouting:
|
||||||
fp := r.unlockedInjectFlow(sender, receiver, p, false)
|
fp := r.unlockedInjectFlow(sender, receiver, p, false)
|
||||||
receiver.InjectUDPPacket(p)
|
receiver.InjectUDPPacket(p)
|
||||||
@@ -779,6 +788,10 @@ func (r *R) RouteForAllExitFuncOrTimeout(timeout time.Duration, whatDo ExitFunc)
|
|||||||
p.Release()
|
p.Release()
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
case Drop:
|
||||||
|
// Record it so the flow log shows the attempt, but never hand it to the receiver
|
||||||
|
r.unlockedInjectFlow(cm[x], receiver, p, false)
|
||||||
|
|
||||||
case KeepRouting:
|
case KeepRouting:
|
||||||
fp := r.unlockedInjectFlow(cm[x], receiver, p, false)
|
fp := r.unlockedInjectFlow(cm[x], receiver, p, false)
|
||||||
receiver.InjectUDPPacket(p)
|
receiver.InjectUDPPacket(p)
|
||||||
@@ -884,6 +897,10 @@ func (r *R) RouteForAllExitFunc(whatDo ExitFunc) {
|
|||||||
p.Release()
|
p.Release()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
case Drop:
|
||||||
|
// Record it so the flow log shows the attempt, but never hand it to the receiver
|
||||||
|
r.unlockedInjectFlow(cm[x], receiver, p, false)
|
||||||
|
|
||||||
case KeepRouting:
|
case KeepRouting:
|
||||||
fp := r.unlockedInjectFlow(cm[x], receiver, p, false)
|
fp := r.unlockedInjectFlow(cm[x], receiver, p, false)
|
||||||
receiver.InjectUDPPacket(p)
|
receiver.InjectUDPPacket(p)
|
||||||
|
|||||||
@@ -231,6 +231,35 @@ punchy:
|
|||||||
# Overriding this to "" is the same as "/" and will allow overwriting any path on the host.
|
# Overriding this to "" is the same as "/" and will allow overwriting any path on the host.
|
||||||
#sandbox_dir: /var/tmp/nebula-debug
|
#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=<vpn 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.
|
# EXPERIMENTAL: relay support for networks that can't establish direct connections.
|
||||||
relay:
|
relay:
|
||||||
# Relays are a list of Nebula IP's that peers can use to relay packets to me.
|
# Relays are a list of Nebula IP's that peers can use to relay packets to me.
|
||||||
|
|||||||
@@ -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=<vpn 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
return nil, util.ContextualizeIfNeeded("Failed to start stats emitter", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
infoAPI := newInfoAPIServerFromConfig(ctx, l, pki, hostMap, c)
|
||||||
|
|
||||||
if configTest {
|
if configTest {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -279,6 +281,7 @@ func Main(c *config.C, configTest bool, buildVersion string, l *slog.Logger, dev
|
|||||||
sshStart: sshStart,
|
sshStart: sshStart,
|
||||||
statsStart: stats.Start,
|
statsStart: stats.Start,
|
||||||
dnsStart: ds.Start,
|
dnsStart: ds.Start,
|
||||||
|
infoAPIStart: infoAPI.Start,
|
||||||
lighthouseStart: lightHouse.StartUpdateWorker,
|
lighthouseStart: lightHouse.StartUpdateWorker,
|
||||||
networkChangeStart: networkChanges.Start,
|
networkChangeStart: networkChanges.Start,
|
||||||
connectionManagerStart: connManager.Start,
|
connectionManagerStart: connManager.Start,
|
||||||
|
|||||||
Reference in New Issue
Block a user