Files
nebula/commands.go
T
Matt RichardsonandClaude Fable 5.1 613c7e3061 Add the identity command and serve it on an opt-in HTTP info API
Build slackhq/nebula#1821 ("who is this vpn address?" over a local
HTTP+JSON socket) on the command registry, so the lookup is one command
on every surface and the socket is one more local API.

identity
- `identity [-address <vpn addr>[:port]]`: no address reports this node;
  with one, this node or the peer behind an established tunnel
- Certificate fields only: name, vpn addresses, networks, unsafe
  networks, groups, fingerprint, issuer, validity, cert version; a peer's
  fingerprint is the one nebula computed when it verified the cert
- Port accepted and stripped, 4in6 unmapped, so a RemoteAddr passes
  straight through
- No tunnel is a commands.NotFound; an empty -address is refused
- Lighthouse TXT lookup shares the owner and peer lookups, keeps answering
  with the initiating certificate, and no longer panics without a default cert
- Served on ssh and nebula ctl too

Info API
- Off until `local_api.info.enabled` with `socket` and optional quoted
  octal `socket_mode` (default "0600"); nothing derived
- Serves commands declaring Command.Identity plus help; others 403 naming
  their surfaces. TestInfoAPISurface pins the set to identity
- GET /v1 is the schema; GET /v1/<cmd>?<flag>=<value> runs one; body is
  the -json rendering via commands.Render; commands.PresentationFormat
  reads json/pretty from the query the way a line is read
- 400 commands.ParamError, 403 commands.WithheldError (what Resolve now
  returns for a withheld name, so help and dispatch type-check it too),
  404 NotFound or unknown, 500 Run error, refusal with no reason, or
  recovered panic with no detail; no redirects; every miss is JSON
- listen takes a mode: bind under umask 0077 then chmod; "0660" plus a
  searchable dir opens it to a group
- The chmod, at bind and on a mode-only reload, goes through chmodSocket,
  which never follows a link: a user who can write the socket's directory
  could otherwise swap the node for a link and have a root daemon put
  0660 or 0666 on any file. Only a socket we own is chmoded. On linux the
  node is held open O_PATH|O_NOFOLLOW, checked by fstat, and chmoded
  through /proc/self/fd, so the check and the chmod land on one inode;
  fchmodat2 is the fallback without /proc. On darwin and the BSDs it is
  an Lstat check and fchmodat(AT_SYMLINK_NOFOLLOW). clearStale Lstats
  too, so a dangling link is refused rather than followed by bind on
  darwin
- One endpoint type owns bind under mutex, bind-before-release move,
  context watch and stop/unlink; each server supplies what it serves
- Reloadable through the same reconcile shape as the control socket:
  mode applied in place, unparsable section keeps what runs, control
  socket path refused by name
- Wire returns Endpoints{Control, Info} with one Stop
- Windows unsupported for now (a pipe has no mode); warned once

Tests: identity lookups and shapes; API filter, status codes, panic
recovery, socket mode, rebind, cancellation; config parsing incl. bare
0660; Wire transitions; e2e of two nebulas with the info API resolving
the peer by address and port, through shared test.HTTPDo/HTTPGet helpers.
Goldens gain identity.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-21 12:26:09 -04:00

651 lines
19 KiB
Go

package nebula
import (
"encoding/json"
"flag"
"fmt"
"io"
"log/slog"
"maps"
"net/netip"
"sort"
"strings"
"github.com/slackhq/nebula/cert"
"github.com/slackhq/nebula/commands"
"github.com/slackhq/nebula/config"
"github.com/slackhq/nebula/header"
"github.com/slackhq/nebula/logging"
)
// The commands nebula serves. attachCommands builds the one registry every surface
// reads; the ssh debug server is a consumer of it, not its owner, which is why this
// is not ssh.go. See the commands package for what a Command is.
type listHostMapFlags struct {
ByIndex bool
}
type printCertFlags struct {
Raw bool
}
type changeRemoteFlags struct {
Address string
}
type closeTunnelFlags struct {
LocalOnly bool
}
type createTunnelFlags struct {
Address string
}
// lighthouseInfo is one entry of list-lighthouse-addrmap.
type lighthouseInfo struct {
VpnAddr string `json:"vpnAddr"`
Addrs *CacheMap `json:"addrs"`
}
// deviceInfo is the result of device-info.
type deviceInfo struct {
Name string `json:"name"`
Cidr []netip.Prefix `json:"cidr"`
}
// certPEM is the result of print-cert -raw. The PEM form is a different thing to emit,
// not a different formatting of the certificate, so it is its own shape.
type certPEM struct {
PEM string `json:"pem"`
}
// Result shapes for print-relays. Field names are the JSON keys; changing them would
// change the output.
type relayFor struct {
Error string `json:",omitempty"`
Type string
State string
PeerAddr netip.Addr
LocalIndex uint32
RemoteIndex uint32
RelayedThrough []netip.Addr
}
type relayOutput struct {
NebulaAddr netip.Addr
RelayForAddrs []relayFor
}
type relaysOutput struct {
Relays []*relayOutput
}
type versionInfo struct {
Version string `json:"version"`
}
type logLevelInfo struct {
Level string `json:"level"`
}
type logFormatInfo struct {
Format string `json:"format"`
}
// defineListHostMapFlags declares the flags list-hostmap and list-pending-hostmap share.
func defineListHostMapFlags(fl *flag.FlagSet, s *listHostMapFlags) {
fl.BoolVar(&s.ByIndex, "by-index", false, "gets all hosts in the hostmap from the index table")
}
func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Interface) {
reg.Register(commands.Flagged(&commands.Command{
Name: commands.ListHostmap,
ShortDescription: "List all known previously connected hosts",
Text: commands.Typed(textListHostMap),
}, defineListHostMapFlags, func(s *listHostMapFlags, a []string) (commands.Result, error) {
return runListHostMap(f.hostMap, s)
}))
reg.Register(commands.Flagged(&commands.Command{
Name: commands.ListPendingHostmap,
ShortDescription: "List all handshaking hosts",
Text: commands.Typed(textListHostMap),
}, defineListHostMapFlags, func(s *listHostMapFlags, a []string) (commands.Result, error) {
return runListHostMap(f.handshakeManager, s)
}))
reg.Register(&commands.Command{
Name: commands.ListLighthouseAddrmap,
ShortDescription: "List all lighthouse map entries",
Run: func(fs any, a []string) (commands.Result, error) {
return runListLighthouseMap(f.lightHouse)
},
Text: commands.Typed(textListLighthouseMap),
})
reg.Register(&commands.Command{
Name: commands.Reload,
ShortDescription: "Reloads configuration from disk, same as sending HUP to the process",
// The line goes out before the reload: a reload that restarts the ssh server ends
// the session that asked, and this is what it sees.
Run: func(fs any, a []string) (commands.Result, error) {
return commands.Did("Reloading config"), nil
},
Then: func() commands.Result {
return runReload(l, c)
},
})
reg.Register(&commands.Command{
Name: commands.LogLevel,
ShortDescription: "Gets or sets the current log level",
Run: func(fs any, a []string) (commands.Result, error) {
return runLogLevel(l, a)
},
Text: commands.Typed(textLogLevel),
})
reg.Register(&commands.Command{
Name: commands.LogFormat,
ShortDescription: "Gets or sets the current log format",
Run: func(fs any, a []string) (commands.Result, error) {
return runLogFormat(l, a)
},
Text: commands.Typed(textLogFormat),
})
reg.Register(&commands.Command{
Name: commands.Version,
ShortDescription: "Prints the currently running version of nebula",
Run: func(fs any, a []string) (commands.Result, error) {
return commands.Value(versionInfo{Version: f.version}), nil
},
Text: commands.Typed(textVersion),
})
reg.Register(&commands.Command{
Name: commands.DeviceInfo,
ShortDescription: "Prints information about the network device.",
Run: func(fs any, a []string) (commands.Result, error) {
return commands.Value(runDeviceInfo(f)), nil
},
Text: commands.Typed(textDeviceInfo),
})
reg.Register(commands.Flagged(&commands.Command{
Name: commands.PrintCert,
ShortDescription: "Prints the current certificate being used or the certificate for the provided vpn addr",
Text: textPrintCert,
}, func(fl *flag.FlagSet, s *printCertFlags) {
fl.BoolVar(&s.Raw, "raw", false, "prints the PEM encoded certificate; with -json, an object holding it")
}, func(s *printCertFlags, a []string) (commands.Result, error) {
return runPrintCert(f, s, a)
}))
reg.Register(commands.Flagged(&commands.Command{
Name: commands.Identity,
ShortDescription: "Prints the certificate identity of the host owning the provided vpn addr",
Help: "Identity is what the certificate says: name, vpn addrs, networks, groups, fingerprint, issuer and validity. The address may carry a port, so a connection's remote address can be passed through as is. For this node's own identity use whoami.",
Identity: true,
Text: commands.Typed(textIdentity),
}, func(fl *flag.FlagSet, s *identityFlags) {
fl.Func("address", "The vpn addr to look up, optionally with a port", s.setAddress)
}, func(s *identityFlags, a []string) (commands.Result, error) {
return runIdentity(f, s, a)
}))
reg.Register(&commands.Command{
Name: commands.Whoami,
ShortDescription: "Prints the certificate identity of this node",
Help: "The fields identity prints, read from this node's own certificate.",
Identity: true,
Text: commands.Typed(textIdentity),
Run: func(fs any, a []string) (commands.Result, error) {
if len(a) > 0 {
return commands.Failed("whoami takes no arguments"), nil
}
return runWhoami(f)
},
})
reg.Register(&commands.Command{
Name: commands.PrintTunnel,
ShortDescription: "Prints json details about a tunnel for the provided vpn addr",
Run: func(fs any, a []string) (commands.Result, error) {
return runPrintTunnel(f, a)
},
// No Text on purpose: nothing human readable exists yet, and shipping an interim
// form would invite scripts against it. ssh defaults these to JSON; ctl asks.
JSONOnly: true,
})
reg.Register(&commands.Command{
Name: commands.PrintRelays,
ShortDescription: "Prints json details about all relay info",
Run: func(fs any, a []string) (commands.Result, error) {
return commands.Value(runPrintRelays(f)), nil
},
// No Text on purpose; see print-tunnel.
JSONOnly: true,
})
reg.Register(commands.Flagged(&commands.Command{
Name: commands.ChangeRemote,
ShortDescription: "Changes the remote address used in the tunnel for the provided vpn addr",
}, func(fl *flag.FlagSet, s *changeRemoteFlags) {
fl.StringVar(&s.Address, "address", "", "The new remote address, ip:port")
}, func(s *changeRemoteFlags, a []string) (commands.Result, error) {
return runChangeRemote(f, s, a)
}))
reg.Register(commands.Flagged(&commands.Command{
Name: commands.CloseTunnel,
ShortDescription: "Closes a tunnel for the provided vpn addr",
}, func(fl *flag.FlagSet, s *closeTunnelFlags) {
fl.BoolVar(&s.LocalOnly, "local-only", false, "Disables notifying the remote that the tunnel is shutting down")
}, func(s *closeTunnelFlags, a []string) (commands.Result, error) {
return runCloseTunnel(f, s, a)
}))
reg.Register(commands.Flagged(&commands.Command{
Name: commands.CreateTunnel,
ShortDescription: "Creates a tunnel for the provided vpn address",
Help: "The lighthouses will be queried for real addresses but you can provide one as well.",
}, func(fl *flag.FlagSet, s *createTunnelFlags) {
fl.StringVar(&s.Address, "address", "", "Optionally provide a real remote address, ip:port ")
}, func(s *createTunnelFlags, a []string) (commands.Result, error) {
return runCreateTunnel(f, s, a)
}))
reg.Register(&commands.Command{
Name: commands.QueryLighthouse,
ShortDescription: "Query the lighthouses for the provided vpn address",
Help: "This command is asynchronous. Only currently known udp addresses will be printed.",
Run: func(fs any, a []string) (commands.Result, error) {
return runQueryLighthouse(f, a)
},
// No Text on purpose; see print-tunnel.
JSONOnly: true,
})
}
// runListHostMap computes the hostmap listing. Presentation is Render's job.
func runListHostMap(hl controlHostLister, fs *listHostMapFlags) (commands.Result, error) {
var hm []ControlHostInfo
if fs.ByIndex {
hm = listHostMapIndexes(hl)
} else {
hm = listHostMapHosts(hl)
}
sort.Slice(hm, func(i, j int) bool {
return hm[i].VpnAddrs[0].Compare(hm[j].VpnAddrs[0]) < 0
})
return commands.Value(hm), nil
}
// textListHostMap is the human rendering, shared by every surface.
func textListHostMap(hm []ControlHostInfo, w io.Writer) error {
for _, h := range hm {
if _, err := fmt.Fprintf(w, "%s: %s\n", h.VpnAddrs, h.RemoteAddrs); err != nil {
return err
}
}
return nil
}
func runListLighthouseMap(lightHouse *LightHouse) (commands.Result, error) {
lightHouse.RLock()
addrMap := make([]lighthouseInfo, len(lightHouse.addrMap))
x := 0
for k, v := range lightHouse.addrMap {
addrMap[x] = lighthouseInfo{
VpnAddr: k.String(),
Addrs: v.CopyCache(),
}
x++
}
lightHouse.RUnlock()
sort.Slice(addrMap, func(i, j int) bool {
return strings.Compare(addrMap[i].VpnAddr, addrMap[j].VpnAddr) < 0
})
return commands.Value(addrMap), nil
}
func textListLighthouseMap(addrMap []lighthouseInfo, w io.Writer) error {
for _, e := range addrMap {
b, err := json.Marshal(e.Addrs)
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "%s: %s\n", e.VpnAddr, string(b)); err != nil {
return err
}
}
return nil
}
func textVersion(vi versionInfo, w io.Writer) error {
_, err := fmt.Fprintf(w, "%s\n", vi.Version)
return err
}
// vpnAddrOperand parses the vpn address a command takes as its first operand. noun is
// how the command's messages name it, "vpn address" or "vpn addr", which the output
// golden pins per command. With no operand, or one that does not parse, ok is false and
// res is the refusal to return.
func vpnAddrOperand(a []string, noun string) (vpnAddr netip.Addr, res commands.Result, ok bool) {
if len(a) == 0 {
return netip.Addr{}, commands.Failed("No vpn address was provided"), false
}
vpnAddr, err := netip.ParseAddr(a[0])
if err != nil {
return netip.Addr{}, commands.Failed("The provided %s could not be parsed: %s", noun, a[0]), false
}
return vpnAddr, commands.Result{}, true
}
// tunnelOperand is vpnAddrOperand followed by the lookup of the tunnel it names.
func tunnelOperand(ifce *Interface, a []string, noun string) (*HostInfo, commands.Result, bool) {
vpnAddr, res, ok := vpnAddrOperand(a, noun)
if !ok {
return nil, res, false
}
hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr)
if hostInfo == nil {
return nil, commands.Failed("Could not find tunnel for %s: %v", noun, a[0]), false
}
return hostInfo, commands.Result{}, true
}
func runQueryLighthouse(ifce *Interface, a []string) (commands.Result, error) {
vpnAddr, res, ok := vpnAddrOperand(a, "vpn address")
if !ok {
return res, nil
}
var cm *CacheMap
rl := ifce.lightHouse.Query(vpnAddr)
if rl != nil {
cm = rl.CopyCache()
}
return commands.Value(cm), nil
}
func runCloseTunnel(ifce *Interface, flags *closeTunnelFlags, a []string) (commands.Result, error) {
hostInfo, res, ok := tunnelOperand(ifce, a, "vpn address")
if !ok {
return res, nil
}
if !flags.LocalOnly {
ifce.send(
header.CloseTunnel,
0,
hostInfo.ConnectionState,
hostInfo,
[]byte{},
make([]byte, 12, 12),
make([]byte, mtu),
)
}
ifce.closeTunnel(hostInfo)
return commands.Did("Closed"), nil
}
func runCreateTunnel(ifce *Interface, flags *createTunnelFlags, a []string) (commands.Result, error) {
vpnAddr, res, ok := vpnAddrOperand(a, "vpn address")
if !ok {
return res, nil
}
if hostInfo := ifce.hostMap.QueryVpnAddr(vpnAddr); hostInfo != nil {
return commands.Failed("Tunnel already exists"), nil
}
if hostInfo := ifce.handshakeManager.QueryVpnAddr(vpnAddr); hostInfo != nil {
return commands.Failed("Tunnel already handshaking"), nil
}
var addr netip.AddrPort
if flags.Address != "" {
var err error
addr, err = netip.ParseAddrPort(flags.Address)
if err != nil {
return commands.Failed("Address could not be parsed"), nil
}
}
// Set the remote from the callback, which StartHandshake runs under the handshake manager lock
// before the hostinfo reaches the handshake loop or the static-host trigger, so the address is
// in the RemoteList before the first handshake is sent.
ifce.handshakeManager.StartHandshake(vpnAddr, func(hh *HandshakeHostInfo) {
if addr.IsValid() {
hh.hostinfo.SetRemote(addr)
}
})
return commands.Did("Created"), nil
}
func runChangeRemote(ifce *Interface, flags *changeRemoteFlags, a []string) (commands.Result, error) {
if len(a) == 0 {
return commands.Failed("No vpn address was provided"), nil
}
if flags.Address == "" {
return commands.Failed("No address was provided"), nil
}
addr, err := netip.ParseAddrPort(flags.Address)
if err != nil {
return commands.Failed("Address could not be parsed"), nil
}
hostInfo, res, ok := tunnelOperand(ifce, a, "vpn address")
if !ok {
return res, nil
}
hostInfo.SetRemote(addr)
return commands.Did("Changed"), nil
}
func runLogLevel(l *slog.Logger, a []string) (commands.Result, error) {
ctrl, ok := l.Handler().(interface {
GetLevel() slog.Level
SetLevel(slog.Level)
})
if !ok {
return commands.Failed("Log level is not reconfigurable on this logger"), nil
}
if len(a) > 0 {
level, err := logging.ParseLevel(strings.ToLower(a[0]))
if err != nil {
return commands.Failed("Unknown log level %s. Possible log levels: trace, debug, info, warn, error", a), nil
}
ctrl.SetLevel(level)
}
return commands.Value(logLevelInfo{Level: logging.LevelName(ctrl.GetLevel())}), nil
}
func textLogLevel(li logLevelInfo, w io.Writer) error {
_, err := fmt.Fprintf(w, "Log level is: %s\n", li.Level)
return err
}
func runLogFormat(l *slog.Logger, a []string) (commands.Result, error) {
ctrl, ok := l.Handler().(interface {
GetFormat() string
SetFormat(string) error
})
if !ok {
return commands.Failed("Log format is not reconfigurable on this logger"), nil
}
if len(a) > 0 {
if err := ctrl.SetFormat(strings.ToLower(a[0])); err != nil {
return commands.Failed("Unable to set log format: %s", err), nil
}
}
return commands.Value(logFormatInfo{Format: ctrl.GetFormat()}), nil
}
func textLogFormat(fi logFormatInfo, w io.Writer) error {
_, err := fmt.Fprintf(w, "Log format is: %s\n", fi.Format)
return err
}
func runPrintCert(ifce *Interface, args *printCertFlags, a []string) (commands.Result, error) {
c := ifce.pki.getCertState().GetDefaultCertificate()
if len(a) > 0 {
hostInfo, res, ok := tunnelOperand(ifce, a, "vpn addr")
if !ok {
return res, nil
}
// A tunnel still handshaking, or one being torn down, has no certificate.
cc := hostInfo.GetCert()
if cc == nil {
return commands.Failed("Could not find tunnel for vpn addr: %v", a[0]), nil
}
c = cc.Certificate
}
if args.Raw {
b, err := c.MarshalPEM()
if err != nil {
return commands.Failed("Unable to marshal the certificate: %s", err), nil
}
return commands.Value(certPEM{PEM: string(b)}), nil
}
return commands.Value(c), nil
}
// textPrintCert takes the value untyped: print-cert returns a certificate or, with -raw,
// its PEM, and those are two things to emit rather than two renderings of one.
func textPrintCert(v any, w io.Writer) error {
switch c := v.(type) {
case certPEM:
_, err := io.WriteString(w, c.PEM)
return err
case cert.Certificate:
_, err := io.WriteString(w, c.String()+"\n")
return err
default:
return fmt.Errorf("expected a certificate, got %T", v)
}
}
func runPrintRelays(ifce *Interface) any {
ifce.hostMap.RLock()
relays := maps.Clone(ifce.hostMap.Relays)
ifce.hostMap.RUnlock()
co := relaysOutput{}
for k, v := range relays {
ro := relayOutput{NebulaAddr: v.vpnAddrs[0]}
co.Relays = append(co.Relays, &ro)
relayHI := ifce.hostMap.QueryVpnAddr(v.vpnAddrs[0])
if relayHI == nil {
ro.RelayForAddrs = append(ro.RelayForAddrs, relayFor{Error: "could not find hostinfo"})
continue
}
for _, vpnAddr := range relayHI.relayState.CopyRelayForIps() {
rf := relayFor{}
r, ok := relayHI.relayState.GetRelayForByAddr(vpnAddr)
if ok {
t := ""
switch r.Type {
case ForwardingType:
t = "forwarding"
case TerminalType:
t = "terminal"
default:
t = "unknown"
}
st := ""
switch r.State {
case Requested:
st = "requested"
case Established:
st = "established"
default:
st = "unknown"
}
rf.LocalIndex = r.LocalIndex
rf.RemoteIndex = r.RemoteIndex
rf.PeerAddr = r.PeerAddr
rf.Type = t
rf.State = st
if rf.LocalIndex != k {
rf.Error = fmt.Sprintf("hostmap LocalIndex '%v' does not match RelayState LocalIndex", k)
}
}
relayedHI := ifce.hostMap.QueryVpnAddr(vpnAddr)
if relayedHI != nil {
rf.RelayedThrough = append(rf.RelayedThrough, relayedHI.relayState.CopyRelayIps()...)
}
ro.RelayForAddrs = append(ro.RelayForAddrs, rf)
}
}
return co
}
func runPrintTunnel(ifce *Interface, a []string) (commands.Result, error) {
hostInfo, res, ok := tunnelOperand(ifce, a, "vpn addr")
if !ok {
return res, nil
}
return commands.Value(copyHostInfo(hostInfo, ifce.hostMap.GetPreferredRanges())), nil
}
func runDeviceInfo(ifce *Interface) deviceInfo {
d := deviceInfo{
Name: ifce.inside.Name(),
Cidr: make([]netip.Prefix, len(ifce.inside.Networks())),
}
copy(d.Cidr, ifce.inside.Networks())
return d
}
func textDeviceInfo(d deviceInfo, w io.Writer) error {
_, err := fmt.Fprintf(w, "name=%v cidr=%v\n", d.Name, d.Cidr)
return err
}
// runReload reloads the config and reports how it went. A reload that failed is a
// refusal, exit 1 over ssh; one that merely needs a restart is not.
func runReload(l *slog.Logger, c *config.C) commands.Result {
res := c.ReloadConfig()
res.Log(l)
if res.Err != nil || res.Failed {
return commands.Failed("%s", res.String())
}
return commands.Did("%s", res.String())
}