mirror of
https://github.com/slackhq/nebula.git
synced 2026-08-15 17:07:00 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cdb94b2a0 |
@@ -52,7 +52,6 @@ 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)
|
||||||
@@ -109,9 +108,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-3
@@ -258,12 +258,31 @@ func (d *dnsServer) QueryCert(data string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
crt := findCertificateForVpnAddr(d.certState(), d.hostMap, ip)
|
// The hostmap only ever contains peers we have handshaked with, so it never carries an entry for ourselves.
|
||||||
if crt == nil {
|
// 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 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := crt.MarshalJSON()
|
q := hostinfo.GetCert()
|
||||||
|
if q == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := q.Certificate.MarshalJSON()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,35 +231,6 @@ 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.
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ require (
|
|||||||
filippo.io/bigmod v0.1.0
|
filippo.io/bigmod v0.1.0
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
|
||||||
github.com/armon/go-radix v1.0.0
|
github.com/armon/go-radix v1.0.0
|
||||||
github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432
|
|
||||||
github.com/flynn/noise v1.1.0
|
github.com/flynn/noise v1.1.0
|
||||||
github.com/gaissmai/bart v0.28.0
|
github.com/gaissmai/bart v0.28.0
|
||||||
github.com/gogo/protobuf v1.3.2
|
github.com/gogo/protobuf v1.3.2
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432 h1:M5QgkYacWj0Xs8MhpIK/5uwU02icXpEoSo9sM2aRCps=
|
|
||||||
github.com/cyberdelia/go-metrics-graphite v0.0.0-20161219230853-39f87cc3b432/go.mod h1:xwIwAxMvYnVrGJPe2FKx5prTrnAjGOD8zvDOnxnrrkM=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package nebula
|
||||||
|
|
||||||
|
// This file is a trimmed, inlined copy of the graphite exporter from
|
||||||
|
// github.com/cyberdelia/go-metrics-graphite, retaining only the Config type and
|
||||||
|
// the Once entrypoint that Nebula uses. The upstream package has been
|
||||||
|
// unmaintained for 10+ years, so it was vendored here to drop the dependency.
|
||||||
|
// See https://github.com/slackhq/nebula/issues/1831.
|
||||||
|
//
|
||||||
|
// Copyright 2015 Timothée Peignier. All rights reserved.
|
||||||
|
//
|
||||||
|
// Redistribution and use in source and binary forms, with or without
|
||||||
|
// modification, are permitted provided that the following conditions are met:
|
||||||
|
//
|
||||||
|
// 1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
// this list of conditions and the following disclaimer.
|
||||||
|
//
|
||||||
|
// 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
// this list of conditions and the following disclaimer in the documentation
|
||||||
|
// and/or other materials provided with the distribution.
|
||||||
|
//
|
||||||
|
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rcrowley/go-metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// graphiteConfigExport provides a container with configuration parameters for
|
||||||
|
// the Graphite exporter.
|
||||||
|
type graphiteConfigExport struct {
|
||||||
|
Addr *net.TCPAddr // Network address to connect to
|
||||||
|
Registry metrics.Registry // Registry to be exported
|
||||||
|
FlushInterval time.Duration // Flush interval
|
||||||
|
DurationUnit time.Duration // Time conversion unit for durations
|
||||||
|
Prefix string // Prefix to be prepended to metric names
|
||||||
|
Percentiles []float64 // Percentiles to export from timers and histograms
|
||||||
|
}
|
||||||
|
|
||||||
|
// graphiteOnce performs a single submission to Graphite, returning a non-nil
|
||||||
|
// error on failed connections.
|
||||||
|
func graphiteOnce(c graphiteConfigExport) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
du := float64(c.DurationUnit)
|
||||||
|
flushSeconds := float64(c.FlushInterval) / float64(time.Second)
|
||||||
|
conn, err := net.DialTCP("tcp", nil, c.Addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
w := bufio.NewWriter(conn)
|
||||||
|
c.Registry.Each(func(name string, i any) {
|
||||||
|
switch metric := i.(type) {
|
||||||
|
case metrics.Counter:
|
||||||
|
count := metric.Count()
|
||||||
|
fmt.Fprintf(w, "%s.%s.count %d %d\n", c.Prefix, name, count, now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.count_ps %.2f %d\n", c.Prefix, name, float64(count)/flushSeconds, now)
|
||||||
|
case metrics.Gauge:
|
||||||
|
fmt.Fprintf(w, "%s.%s.value %d %d\n", c.Prefix, name, metric.Value(), now)
|
||||||
|
case metrics.GaugeFloat64:
|
||||||
|
fmt.Fprintf(w, "%s.%s.value %f %d\n", c.Prefix, name, metric.Value(), now)
|
||||||
|
case metrics.Histogram:
|
||||||
|
h := metric.Snapshot()
|
||||||
|
ps := h.Percentiles(c.Percentiles)
|
||||||
|
fmt.Fprintf(w, "%s.%s.count %d %d\n", c.Prefix, name, h.Count(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.min %d %d\n", c.Prefix, name, h.Min(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.max %d %d\n", c.Prefix, name, h.Max(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.mean %.2f %d\n", c.Prefix, name, h.Mean(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.std-dev %.2f %d\n", c.Prefix, name, h.StdDev(), now)
|
||||||
|
for psIdx, psKey := range c.Percentiles {
|
||||||
|
key := strings.Replace(strconv.FormatFloat(psKey*100.0, 'f', -1, 64), ".", "", 1)
|
||||||
|
fmt.Fprintf(w, "%s.%s.%s-percentile %.2f %d\n", c.Prefix, name, key, ps[psIdx], now)
|
||||||
|
}
|
||||||
|
case metrics.Meter:
|
||||||
|
m := metric.Snapshot()
|
||||||
|
fmt.Fprintf(w, "%s.%s.count %d %d\n", c.Prefix, name, m.Count(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.one-minute %.2f %d\n", c.Prefix, name, m.Rate1(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.five-minute %.2f %d\n", c.Prefix, name, m.Rate5(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.fifteen-minute %.2f %d\n", c.Prefix, name, m.Rate15(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.mean %.2f %d\n", c.Prefix, name, m.RateMean(), now)
|
||||||
|
case metrics.Timer:
|
||||||
|
t := metric.Snapshot()
|
||||||
|
ps := t.Percentiles(c.Percentiles)
|
||||||
|
count := t.Count()
|
||||||
|
fmt.Fprintf(w, "%s.%s.count %d %d\n", c.Prefix, name, count, now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.count_ps %.2f %d\n", c.Prefix, name, float64(count)/flushSeconds, now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.min %d %d\n", c.Prefix, name, t.Min()/int64(du), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.max %d %d\n", c.Prefix, name, t.Max()/int64(du), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.mean %.2f %d\n", c.Prefix, name, t.Mean()/du, now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.std-dev %.2f %d\n", c.Prefix, name, t.StdDev()/du, now)
|
||||||
|
for psIdx, psKey := range c.Percentiles {
|
||||||
|
key := strings.Replace(strconv.FormatFloat(psKey*100.0, 'f', -1, 64), ".", "", 1)
|
||||||
|
fmt.Fprintf(w, "%s.%s.%s-percentile %.2f %d\n", c.Prefix, name, key, ps[psIdx]/du, now)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s.%s.one-minute %.2f %d\n", c.Prefix, name, t.Rate1(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.five-minute %.2f %d\n", c.Prefix, name, t.Rate5(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.fifteen-minute %.2f %d\n", c.Prefix, name, t.Rate15(), now)
|
||||||
|
fmt.Fprintf(w, "%s.%s.mean-rate %.2f %d\n", c.Prefix, name, t.RateMean(), now)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
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,8 +260,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -281,7 +279,6 @@ 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,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
graphite "github.com/cyberdelia/go-metrics-graphite"
|
|
||||||
mp "github.com/nbrownus/go-metrics-prometheus"
|
mp "github.com/nbrownus/go-metrics-prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
@@ -253,7 +252,7 @@ func (s *statsServer) buildRuntime(cfg statsConfig) ([]func(), *http.Server) {
|
|||||||
// loadStatsConfig already resolved and validated the address; re-parse
|
// loadStatsConfig already resolved and validated the address; re-parse
|
||||||
// the resolved form (no DNS lookup) to get a *net.TCPAddr.
|
// the resolved form (no DNS lookup) to get a *net.TCPAddr.
|
||||||
addr, _ := net.ResolveTCPAddr(cfg.graphite.protocol, cfg.graphite.resolvedAddr)
|
addr, _ := net.ResolveTCPAddr(cfg.graphite.protocol, cfg.graphite.resolvedAddr)
|
||||||
gcfg := graphite.Config{
|
gcfg := graphiteConfigExport{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Registry: metrics.DefaultRegistry,
|
Registry: metrics.DefaultRegistry,
|
||||||
FlushInterval: cfg.interval,
|
FlushInterval: cfg.interval,
|
||||||
@@ -262,7 +261,7 @@ func (s *statsServer) buildRuntime(cfg statsConfig) ([]func(), *http.Server) {
|
|||||||
Percentiles: []float64{0.5, 0.75, 0.95, 0.99, 0.999},
|
Percentiles: []float64{0.5, 0.75, 0.95, 0.99, 0.999},
|
||||||
}
|
}
|
||||||
captureFns = append(captureFns, func() {
|
captureFns = append(captureFns, func() {
|
||||||
if err := graphite.Once(gcfg); err != nil {
|
if err := graphiteOnce(gcfg); err != nil {
|
||||||
s.l.Error("Graphite export failed", "error", err)
|
s.l.Error("Graphite export failed", "error", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -371,7 +371,7 @@ func waitForListening(t *testing.T, addr string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// graphiteSink is a minimal TCP accept-and-discard server so graphite.Once
|
// graphiteSink is a minimal TCP accept-and-discard server so graphiteOnce
|
||||||
// calls in tests don't spam error logs or wedge on connection refused.
|
// calls in tests don't spam error logs or wedge on connection refused.
|
||||||
type graphiteSink struct {
|
type graphiteSink struct {
|
||||||
ln net.Listener
|
ln net.Listener
|
||||||
|
|||||||
Reference in New Issue
Block a user