diff --git a/CHANGELOG.md b/CHANGELOG.md index 668a9b75..35ae8d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A control socket that provides access to all of the ssh debug server's commands along with support for running `nebula ctl ` as a client from the CLI. +- An info socket that provides access to information related to identity: identity, whoami, and print-cert. ### Removed diff --git a/cmd/nebula-service/main.go b/cmd/nebula-service/main.go index da5f5b4a..9727889b 100644 --- a/cmd/nebula-service/main.go +++ b/cmd/nebula-service/main.go @@ -117,15 +117,15 @@ func main() { os.Exit(1) } - ctl := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands()) + local := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands()) go ctrl.ShutdownBlock() err := ctrl.Wait() - // os.Exit skips deferred calls, so the socket is removed here, before either exit - // below, rather than left to a deferred Stop that would never run. - ctl.Stop() + // os.Exit skips deferred calls, so the sockets are removed here, before either + // exit below, rather than left to a deferred Stop that would never run. + local.Stop() if err != nil { l.Error("Nebula stopped due to fatal error", "error", err) diff --git a/cmd/nebula-service/service.go b/cmd/nebula-service/service.go index b34801fa..96678a8f 100644 --- a/cmd/nebula-service/service.go +++ b/cmd/nebula-service/service.go @@ -18,7 +18,7 @@ type program struct { configPath *string build string control *nebula.Control - ctl *localapi.Server + local *localapi.Endpoints } func (p *program) Start(s service.Service) error { @@ -52,16 +52,16 @@ func (p *program) Start(s service.Service) error { return err } - // Now that we're started, stand up the local control socket. - p.ctl = localapi.Wire(p.control.Context(), l, c, p.control.Commands()) + // Now that we're started, stand up the local APIs. + p.local = localapi.Wire(p.control.Context(), l, c, p.control.Commands()) // Nebula can stop itself on a fatal packet reader error, make sure to log it if it happens. go func() { if err := p.control.Wait(); err != nil { logger.Error(fmt.Sprintf("Nebula stopped due to fatal error: %v", err)) - // os.Exit skips deferred calls and races the socket's own context watcher, - // so the socket is removed here, as cmd/nebula does. - p.ctl.Stop() + // os.Exit skips deferred calls and races the sockets' own context watchers, + // so they are removed here, as cmd/nebula does. + p.local.Stop() os.Exit(2) } }() @@ -72,8 +72,8 @@ func (p *program) Start(s service.Service) error { func (p *program) Stop(s service.Service) error { logger.Info("Nebula service stopping.") - if p.ctl != nil { - p.ctl.Stop() + if p.local != nil { + p.local.Stop() } if p.control == nil { diff --git a/cmd/nebula/main.go b/cmd/nebula/main.go index 295dc767..ce83dffd 100644 --- a/cmd/nebula/main.go +++ b/cmd/nebula/main.go @@ -103,16 +103,16 @@ func main() { os.Exit(1) } - ctl := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands()) + local := localapi.Wire(ctrl.Context(), l, c, ctrl.Commands()) go ctrl.ShutdownBlock() notifyReady(l) err := ctrl.Wait() - // os.Exit skips deferred calls, so the socket is removed here, before either exit - // below, rather than left to a deferred Stop that would never run. - ctl.Stop() + // os.Exit skips deferred calls, so the sockets are removed here, before either + // exit below, rather than left to a deferred Stop that would never run. + local.Stop() if err != nil { l.Error("Nebula stopped due to fatal error", "error", err) diff --git a/commands.go b/commands.go index 17c6ae23..fc5fde5a 100644 --- a/commands.go +++ b/commands.go @@ -183,6 +183,32 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte 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", diff --git a/commands/command.go b/commands/command.go index 1fbf1a30..d7ae0c59 100644 --- a/commands/command.go +++ b/commands/command.go @@ -34,6 +34,9 @@ type Command struct { ShortDescription string Help string + // Identity indicates this command should be made available via the info socket. + Identity bool + Flags CommandFlags // Run produces the command's Result, leaving presentation to Render. diff --git a/commands/names.go b/commands/names.go index 78bf3bc3..cb55a8c2 100644 --- a/commands/names.go +++ b/commands/names.go @@ -13,6 +13,7 @@ const ( CreateTunnel Name = "create-tunnel" DeviceInfo Name = "device-info" Help Name = "help" + Identity Name = "identity" ListHostmap Name = "list-hostmap" ListLighthouseAddrmap Name = "list-lighthouse-addrmap" ListPendingHostmap Name = "list-pending-hostmap" @@ -25,4 +26,5 @@ const ( QueryLighthouse Name = "query-lighthouse" Reload Name = "reload" Version Name = "version" + Whoami Name = "whoami" ) diff --git a/commands/registry.go b/commands/registry.go index 78be15f9..b969faf7 100644 --- a/commands/registry.go +++ b/commands/registry.go @@ -2,6 +2,7 @@ package commands import ( "cmp" + "errors" "flag" "fmt" "io" @@ -15,9 +16,8 @@ import ( // Registry holds a set of commands. // -// It is safe for concurrent use. Commands may be registered while surfaces are serving -// from the registry: the ssh server clones it per session and the control socket filters -// it per request, and either may overlap a Register. +// It is safe for concurrent use: the ssh server clones it per session and the control +// socket serves it as it is, while the daemon fills it once at startup. type Registry struct { mu sync.RWMutex commands map[Name]*Command @@ -63,15 +63,16 @@ func (r *Registry) registerHelp() { return Value(r.Schema()), nil } - if c := r.Get(Name(args[0])); c != nil { - return Value(r.schemaFor(c)), nil + c, err := r.Resolve(Name(args[0])) + if err != nil { + var withheld *WithheldError + if errors.As(err, &withheld) { + return Failed("%s", err), nil + } + return Failed("Command not available %s", args[0]), nil } - if err := r.unavailable(Name(args[0])); err != nil { - return Failed("%s", err), nil - } - - return Failed("Command not available %s", args[0]), nil + return Value(r.SchemaFor(c)), nil }, Text: renderHelp, }) @@ -222,10 +223,9 @@ func (r *Registry) Clone() *Registry { // Filter returns a filtered copy of the registry. // -// Keep should return true for commands that should be included in the copy. -// -// reason says, to an operator who names a command the copy left out, why this surface -// does not serve it; "" leaves the registry to say only that it does not. +// Keep should return true for commands that should be included in the copy. reason +// says, to an operator who names a command the copy left out, why this surface does +// not serve it; "" leaves the registry to say only that it does not. func (r *Registry) Filter(keep func(*Command) bool, reason string) *Registry { out := &Registry{commands: map[Name]*Command{}, full: r, reason: reason, DefaultFormat: r.DefaultFormat} @@ -267,9 +267,10 @@ func (r *Registry) DispatchArgs(args []string, w Output) error { args = []string{"help"} } - c := r.Get(Name(args[0])) - if c == nil { - if err := r.unavailable(Name(args[0])); err != nil { + c, err := r.Resolve(Name(args[0])) + if err != nil { + var withheld *WithheldError + if errors.As(err, &withheld) { // Saying why, rather than "did not understand", keeps the rule legible and // points at a surface that will actually run the command. _, _ = fmt.Fprintln(w.Err, err) @@ -310,12 +311,18 @@ func (r *Registry) unavailable(name Name) error { reason = "this surface does not serve it" } - return fmt.Errorf("%s is not available on this surface: %s", c.Name, reason) + return &WithheldError{msg: fmt.Sprintf("%s is not available on this surface: %s", c.Name, reason)} } -// Resolve returns the command registered under name, or says why there is none: the -// filter's reason for one this surface withholds, or unknown for a name that does not -// exist. +// WithheldError indicates that the command is supported by Nebula, but not via this path. +type WithheldError struct { + msg string +} + +func (e *WithheldError) Error() string { return e.msg } + +// Resolve returns the command registered under name, or says why there is none: a +// WithheldError with the filter's reason, or unknown for a name that does not exist. func (r *Registry) Resolve(name Name) (*Command, error) { if c := r.Get(name); c != nil { return c, nil @@ -328,6 +335,20 @@ func (r *Registry) Resolve(name Name) (*Command, error) { return nil, fmt.Errorf("unknown command %q", name) } +// ParamError is what Invoke returns for a parameter the command would not take: one it +// does not have, or a value its flag would not parse. It is the caller's mistake rather +// than the command's failure, and a surface with status codes tells the two apart by +// it; every other error from Invoke came out of Run. +type ParamError struct { + msg string +} + +func (e *ParamError) Error() string { return e.msg } + +func paramErrorf(format string, args ...any) error { + return &ParamError{msg: fmt.Sprintf(format, args...)} +} + // setParams sets each named parameter on fl, in the order of names, and stops at the // first that is unknown or does not parse. own says whether the command declared flags // of its own, which decides between "takes no parameters" and "has no parameter". @@ -339,44 +360,51 @@ func (r *Registry) Resolve(name Name) (*Command, error) { func setParams(fl *flag.FlagSet, name string, names []string, params map[string]string, own bool) error { for _, k := range names { if isPresentationFlag(k) { - return fmt.Errorf("%s is a command line flag, not a parameter; choose the rendering with the request's format field", k) + return paramErrorf("%s is a command line flag, not a parameter; choose the rendering with the request's format field", k) } f := fl.Lookup(k) if f == nil { if !own { - return fmt.Errorf("%s takes no parameters", name) + return paramErrorf("%s takes no parameters", name) } - return fmt.Errorf("%s has no parameter %q", name, k) + return paramErrorf("%s has no parameter %q", name, k) } - v := params[k] - if err := fl.Set(k, v); err != nil { - if IsBoolFlag(f) { - // The flag package's own words for this are "parse error". - return fmt.Errorf("%s: expected a boolean, got %q", k, v) - } - return fmt.Errorf("%s: %w", k, err) + if err := setFlag(fl, f, params[k]); err != nil { + return err } } return nil } +// setFlag applies one value to f, reporting a refusal as a ParamError in words that +// name the value: the flag package's own words for a bad boolean are "parse error". +func setFlag(fl *flag.FlagSet, f *flag.Flag, v string) error { + if err := fl.Set(f.Name, v); err != nil { + if IsBoolFlag(f) { + return paramErrorf("%s: expected a boolean, got %q", f.Name, v) + } + return paramErrorf("%s: %v", f.Name, err) + } + return nil +} + // Schema describes every command in the registry, so a client can offer them without // having been built against this daemon. func (r *Registry) Schema() []CommandSchema { all := r.All() out := make([]CommandSchema, 0, len(all)) for _, c := range all { - out = append(out, r.schemaFor(c)) + out = append(out, r.SchemaFor(c)) } return out } -// schemaFor describes one command. It is the one derivation, for the whole schema and +// SchemaFor describes one command. It is the one derivation, for the whole schema and // for `help ` alike. -func (r *Registry) schemaFor(c *Command) CommandSchema { +func (r *Registry) SchemaFor(c *Command) CommandSchema { fl, _, _ := commandFlags(c) return schemaOf(c, fl) } diff --git a/commands/registry_test.go b/commands/registry_test.go index b29f5b08..a0df728a 100644 --- a/commands/registry_test.go +++ b/commands/registry_test.go @@ -87,6 +87,22 @@ func TestWithheldIsNotReportedAsUnknown(t *testing.T) { assert.Contains(t, out.String(), "did not understand: no-such-command") } +// TestResolveTypesTheWithheldRefusal: a surface with status codes tells a withheld +// command from an unknown one by the error's type, never by asking the unfiltered +// registry, which is the one thing a filtered surface must not consult. +func TestResolveTypesTheWithheldRefusal(t *testing.T) { + r := testRegistry().Filter(notWithheld, testReason) + + _, err := r.Resolve("withheld") + var withheld *WithheldError + require.ErrorAs(t, err, &withheld) + assert.Contains(t, err.Error(), testReason) + + _, err = r.Resolve("no-such-command") + require.Error(t, err) + assert.NotErrorAs(t, err, &withheld, "an unknown name is not a withheld one") +} + // paramRegistry holds one command with a bool flag and a duration flag, and reports what // the command saw when it ran. func paramRegistry(t *testing.T) (*Registry, *[]paramsSeen) { @@ -401,6 +417,46 @@ func TestBadFlagIsAnsweredWithTheCommandsHelp(t *testing.T) { assert.Equal(t, "flag provided but not defined: -nope\nx - has no flags\n -json\n \toutputs as json\n -pretty\n \tpretty prints json, assumes -json\n", errOut.String()) } +// TestParameterMistakesAreParamErrors: a surface with status codes needs to tell a +// parameter the caller got wrong from a command that failed, and Invoke says which by +// the error's type rather than its words. +func TestParameterMistakesAreParamErrors(t *testing.T) { + r := New() + r.Register(&Command{ + Name: "flagged", + Flags: func() (*flag.FlagSet, any) { + fl := flag.NewFlagSet("", flag.ContinueOnError) + s := struct{ N int }{} + fl.IntVar(&s.N, "n", 0, "") + return fl, &s + }, + Run: func(fs any, a []string) (Result, error) { return Result{}, errors.New("ran and failed") }, + }) + r.Register(&Command{ + Name: "bare", + Run: func(fs any, a []string) (Result, error) { return Nothing(), nil }, + }) + + var pe *ParamError + for _, tc := range []struct { + name string + params map[string]string + }{ + {"flagged", map[string]string{"nope": "1"}}, + {"flagged", map[string]string{"n": "x"}}, + {"flagged", map[string]string{FlagJSON: "true"}}, + {"bare", map[string]string{"n": "1"}}, + } { + _, err := Invoke(Request{Command: r.Get(Name(tc.name)), Params: tc.params}) + require.Error(t, err, "%s %v", tc.name, tc.params) + require.ErrorAs(t, err, &pe, "%s %v: %v", tc.name, tc.params, err) + } + + _, err := Invoke(Request{Command: r.Get("flagged"), Params: map[string]string{"n": "1"}}) + require.EqualError(t, err, "ran and failed") + assert.NotErrorAs(t, err, &pe, "a Run error is the command's, not the caller's") +} + // TestRunErrorIsShown: an error Run returns reaches the person at the prompt on the // error stream, as well as the surface that logs it. func TestRunErrorIsShown(t *testing.T) { diff --git a/commands/render.go b/commands/render.go index bd068bf4..1fc1e891 100644 --- a/commands/render.go +++ b/commands/render.go @@ -112,6 +112,13 @@ func renderJSON(v any, w io.Writer, indent bool) error { type Result struct { Outcome Value any + + // NotFound marks a refusal that is about the thing asked for rather than the asking: + // a vpn address with no tunnel behind it, as opposed to one that would not parse. It + // renders exactly as any other refusal, and never reaches the wire; the distinction + // is for a surface whose protocol has a word for it, so an HTTP client is told 404 + // rather than 400 and never has to parse the sentence. + NotFound bool } // Outcome is the part of a Result that says whether the command worked and, in words a @@ -137,6 +144,14 @@ func Failed(format string, args ...any) Result { return Result{Outcome: Outcome{Reason: fmt.Sprintf(format, args...)}} } +// NotFound is Failed for a command asked about something that does not exist. See +// Result.NotFound. +func NotFound(format string, args ...any) Result { + r := Failed(format, args...) + r.NotFound = true + return r +} + // Did is a convenience for the common "it worked, here is what happened" case. func Did(format string, args ...any) Result { return Result{Outcome: Outcome{OK: true, Reason: fmt.Sprintf(format, args...)}} diff --git a/commands/render_test.go b/commands/render_test.go index 9eb3bf36..cc8dc51a 100644 --- a/commands/render_test.go +++ b/commands/render_test.go @@ -103,3 +103,21 @@ func TestResultShapes(t *testing.T) { assert.Equal(t, Outcome{OK: true, Reason: "done"}, Did("done").Payload()) assert.Equal(t, Outcome{Reason: "no"}, Failed("no").Payload()) } + +// TestNotFoundIsARefusalLikeAnyOther: NotFound renders and marshals exactly as Failed +// does, so no surface's output changes by a command adopting it. The mark is on the +// Result alone, for a surface whose protocol has a word for it. +func TestNotFoundIsARefusalLikeAnyOther(t *testing.T) { + r := NotFound("No active tunnel for vpn addr: %s", "10.0.0.5") + assert.False(t, r.OK) + assert.True(t, r.NotFound) + assert.False(t, Failed("nope").NotFound) + + out := &bytes.Buffer{} + require.NoError(t, Render(&Command{Name: "look"}, r, FormatText, out)) + assert.Equal(t, "No active tunnel for vpn addr: 10.0.0.5\n", out.String()) + + out.Reset() + require.NoError(t, Render(&Command{Name: "look"}, r, FormatJSON, out)) + assert.JSONEq(t, `{"ok":false,"reason":"No active tunnel for vpn addr: 10.0.0.5"}`, out.String(), "the mark never reaches the wire") +} diff --git a/commands/request.go b/commands/request.go index 1e9e822f..a54ce325 100644 --- a/commands/request.go +++ b/commands/request.go @@ -80,6 +80,31 @@ func presentationFormat(fl *flag.FlagSet) Format { } } +// PresentationFormat is presentationFormat for a surface that takes a command's options +// by name rather than from a line: the info API's query string. Only the two presentation +// flags are read from params; the command's own parameters are the caller's to pass to +// Invoke, which refuses these two among them. A flag given with no value counts as set, +// as -pretty does on a line, and one that is not a boolean is a ParamError. +func PresentationFormat(params map[string]string) (Format, error) { + fl := flag.NewFlagSet("", flag.ContinueOnError) + addPresentationFlags(fl) + + for _, k := range []string{FlagJSON, FlagPretty} { + v, ok := params[k] + if !ok { + continue + } + if v == "" { + v = "true" + } + if err := setFlag(fl, fl.Lookup(k), v); err != nil { + return FormatUnset, err + } + } + + return presentationFormat(fl), nil +} + // flagIsTrue reports whether a bool flag was set to true. The flag package's bool value // is a flag.Getter whose Get returns the bool, so this never falls back to parsing text. func flagIsTrue(f *flag.Flag) bool { @@ -108,10 +133,9 @@ func flagIsTrue(f *flag.Flag) bool { // parameters is refused rather than read as a preference. func Invoke(req Request) (Result, error) { c := req.Command - names := slices.Sorted(maps.Keys(req.Params)) fl, fs, own := commandFlags(c) - if err := setParams(fl, string(c.Name), names, req.Params, own); err != nil { + if err := setParams(fl, string(c.Name), slices.Sorted(maps.Keys(req.Params)), req.Params, own); err != nil { return Result{}, err } diff --git a/commands_test.go b/commands_test.go index caf91682..e0e544c1 100644 --- a/commands_test.go +++ b/commands_test.go @@ -6,10 +6,7 @@ import ( "io" "net/netip" "testing" - "time" - "github.com/slackhq/nebula/cert" - "github.com/slackhq/nebula/cert_test" "github.com/slackhq/nebula/commands" "github.com/slackhq/nebula/logging" "github.com/slackhq/nebula/test" @@ -69,36 +66,10 @@ func TestPrintRelaysReportsErrorsAsText(t *testing.T) { assert.Contains(t, marshal(), `"Error":"hostmap LocalIndex '7' does not match RelayState LocalIndex"`) } -// tunnelFixture is an Interface with our own certificate loaded, one peer behind an -// established tunnel and one still handshaking, which has no certificate yet. -func tunnelFixture(t *testing.T) *Interface { - t.Helper() - - l := test.NewLogger() - me := netip.MustParseAddr("10.0.0.1") - peer := netip.MustParsePrefix("10.0.0.2/24") - - ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil) - peerCert, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca, caKey, "peer", time.Time{}, time.Time{}, - []netip.Prefix{peer}, nil, []string{"web", "db"}) - - hm := newHostMap(l) - hm.Hosts[peer.Addr()] = &HostInfo{ - vpnAddrs: []netip.Addr{peer.Addr()}, - ConnectionState: &ConnectionState{peerCert: &cert.CachedCertificate{Certificate: peerCert}}, - } - hm.Hosts[netip.MustParseAddr("10.0.0.3")] = &HostInfo{ - vpnAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.3")}, - ConnectionState: &ConnectionState{}, - } - - return &Interface{pki: newTestPKI(t, "me", []netip.Addr{me}), hostMap: hm} -} - // TestPrintCertOfATunnelWithoutACertificate: a tunnel still handshaking, or one being // torn down, has no certificate, and print-cert refuses rather than panicking. func TestPrintCertOfATunnelWithoutACertificate(t *testing.T) { - f := tunnelFixture(t) + f, _ := identityFixture(t) r, err := runPrintCert(f, &printCertFlags{}, []string{"10.0.0.3"}) require.NoError(t, err) diff --git a/dns_server.go b/dns_server.go index 6a3b9e4d..0303c487 100644 --- a/dns_server.go +++ b/dns_server.go @@ -12,6 +12,7 @@ import ( "sync/atomic" "github.com/miekg/dns" + "github.com/slackhq/nebula/cert" "github.com/slackhq/nebula/config" ) @@ -259,31 +260,20 @@ func (d *dnsServer) QueryCert(data string) string { return "" } - // The hostmap only ever contains peers we have handshaked with, so it never carries an entry for ourselves. - // Answer self lookups straight from the local cert state. - if cs := d.certState(); cs != nil && cs.myVpnAddrsTable != nil && cs.myVpnAddrsTable.Contains(ip) { - c := cs.GetDefaultCertificate() - if c == nil { - return "" - } - b, err := c.MarshalJSON() - if err != nil { - return "" - } - return string(b) + // Our own address answers with the certificate handshakes are initiated with, as the + // TXT record always has; which of our certificates identity reports is its own choice. + cs := d.certState() + var crt cert.Certificate + if ownsVpnAddr(cs, ip) { + crt = cs.getCertificate(cs.initiatingVersion) + } else { + crt, _ = peerCertificateForVpnAddr(d.hostMap, ip) } - - hostinfo := d.hostMap.QueryVpnAddr(ip) - if hostinfo == nil { + if crt == nil { return "" } - q := hostinfo.GetCert() - if q == nil { - return "" - } - - b, err := q.Certificate.MarshalJSON() + b, err := crt.MarshalJSON() if err != nil { return "" } diff --git a/dns_server_test.go b/dns_server_test.go index 73267db2..cbec9a23 100644 --- a/dns_server_test.go +++ b/dns_server_test.go @@ -2,6 +2,7 @@ package nebula import ( "context" + "encoding/json" "log/slog" "net" "net/netip" @@ -402,6 +403,32 @@ func TestDnsServer_QueryCert_returnsOwnCert(t *testing.T) { assert.Empty(t, ds.QueryCert(other.String()+"."), "unknown peer IP should return nothing") } +// TestDnsServer_QueryCert_answersWithTheInitiatingCertificate: with both versions loaded +// the TXT record carries the certificate handshakes start with, as it always has, where +// identity and print-cert report the v2. +func TestDnsServer_QueryCert_answersWithTheInitiatingCertificate(t *testing.T) { + ds, _ := newTestDnsServer(t) + me := netip.MustParseAddr("10.0.0.1") + network := netip.PrefixFrom(me, 24) + ca1, _, caKey1, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil) + v1, _, _, _ := cert_test.NewTestCert(cert.Version1, cert.Curve_CURVE25519, ca1, caKey1, "lighthouse", time.Time{}, time.Time{}, []netip.Prefix{network}, nil, nil) + ca2, _, caKey2, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil) + v2, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca2, caKey2, "lighthouse", time.Time{}, time.Time{}, []netip.Prefix{network}, nil, nil) + + addrsTable := new(bart.Lite) + addrsTable.Insert(netip.PrefixFrom(me, me.BitLen())) + cs := &CertState{v1Cert: v1, v2Cert: v2, initiatingVersion: cert.Version1, myVpnAddrs: []netip.Addr{me}, myVpnAddrsTable: addrsTable} + ds.pki = &PKI{} + ds.pki.cs.Store(cs) + + var got struct { + Version cert.Version `json:"version"` + } + require.NoError(t, json.Unmarshal([]byte(ds.QueryCert(me.String()+".")), &got)) + assert.Equal(t, cert.Version1, got.Version) + assert.Equal(t, v2, ownCertificate(cs), "identity reports the v2 from the same state") +} + func TestDnsServer_reload_disable_stopsRunningServer(t *testing.T) { port := freeUDPPort(t) ds, c := newTestDnsServer(t) diff --git a/e2e/commands_output_test.go b/e2e/commands_output_test.go index 8db79a0b..9f48f677 100644 --- a/e2e/commands_output_test.go +++ b/e2e/commands_output_test.go @@ -101,6 +101,18 @@ func TestCommandOutput(t *testing.T) { {"print-cert", []string{"10.222.0.9", "-json"}}, {"print-cert", []string{"10.222.0.250"}}, {"print-cert", []string{"not-an-address"}}, + {"whoami", nil}, + {"whoami", []string{"x"}}, + // No address is a refusal that names whoami, never this node's identity. + {"identity", nil}, + // Our own address, which the hostmap never holds, answers from our certificate. + {"identity", []string{"-address", "10.222.0.1"}}, + {"identity", []string{"-address", "10.222.0.1:4242"}}, + // A pending tunnel has no certificate yet. + {"identity", []string{"-address", "10.222.0.9"}}, + {"identity", []string{"-address", "not-an-address"}}, + {"identity", []string{"-address", ""}}, + {"identity", []string{"10.222.0.9"}}, {"print-tunnel", nil}, {"print-tunnel", []string{"10.222.0.9"}}, {"print-tunnel", []string{"10.222.0.250"}}, diff --git a/e2e/info_api_test.go b/e2e/info_api_test.go new file mode 100644 index 00000000..c02035b6 --- /dev/null +++ b/e2e/info_api_test.go @@ -0,0 +1,118 @@ +//go:build e2e_testing + +package e2e + +import ( + "context" + "encoding/json" + "net/http" + "net/netip" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/slackhq/nebula/cert" + "github.com/slackhq/nebula/cert_test" + "github.com/slackhq/nebula/e2e/router" + "github.com/slackhq/nebula/localapi" + "github.com/slackhq/nebula/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIdentityOverTheInfoAPI is the info API's reason to exist, end to end: a program on +// this host has a connection arriving over the overlay and asks who is behind its +// source address, and the answer is the identity in the certificate the tunnel was +// established with. +func TestIdentityOverTheInfoAPI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the info API is not served on windows") + } + + ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Now(), time.Now().Add(10*time.Minute), nil, nil, []string{}) + myControl, myVpnIpNet, _, _ := newSimpleServer(cert.Version2, ca, caKey, "me", "10.128.0.1/24", nil) + theirControl, theirVpnIpNet, theirUdpAddr, _ := newSimpleServer(cert.Version2, ca, caKey, "them", "10.128.0.2/24", nil) + + myControl.InjectLightHouseAddr(theirVpnIpNet[0].Addr(), theirUdpAddr) + + r := router.NewR(t, myControl, theirControl) + defer r.RenderFlow() + + myControl.Start() + theirControl.Start() + t.Cleanup(func() { + myControl.Stop() + theirControl.Stop() + }) + + // Before the tunnel is up their address is unknown to us. + path := infoSocket(t) + info := localapi.NewInfoServer(NewTestLogger(), myControl.Commands()) + require.NoError(t, info.Rebind(context.Background(), path, localapi.Access{Mode: 0600})) + t.Cleanup(info.Stop) + + status, body := test.HTTPGet(t, path, "/v1/identity?address="+theirVpnIpNet[0].Addr().String()) + assert.Equal(t, http.StatusNotFound, status) + assert.JSONEq(t, `{"error":"No active tunnel for vpn addr: 10.128.0.2"}`, body) + + // Only we know how to reach them, so the first packet has to be ours; once the tunnel + // is up both directions work and assertTunnel can check it. + myControl.InjectTunPacket(BuildTunUDPPacket(theirVpnIpNet[0].Addr(), 80, myVpnIpNet[0].Addr(), 80, []byte("Hi from me"))) + p := r.RouteForAllUntilTxTun(theirControl) + assertUdpPacket(t, []byte("Hi from me"), p, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), 80, 80) + assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r) + + type identity struct { + Name string `json:"name"` + VpnAddrs []netip.Addr `json:"vpnAddrs"` + Networks []netip.Prefix `json:"networks"` + Groups []string `json:"groups"` + Issuer string `json:"issuer"` + } + + // A connection's RemoteAddr, port and all, resolves to the peer. + status, body = test.HTTPGet(t, path, "/v1/identity?address="+netip.AddrPortFrom(theirVpnIpNet[0].Addr(), 51234).String()) + require.Equal(t, http.StatusOK, status, body) + var them identity + require.NoError(t, json.Unmarshal([]byte(body), &them)) + assert.Equal(t, "them", them.Name) + assert.Equal(t, []netip.Addr{theirVpnIpNet[0].Addr()}, them.VpnAddrs) + assert.Equal(t, theirVpnIpNet, them.Networks) + assert.Equal(t, []string{}, them.Groups) + caFP, err := ca.Fingerprint() + require.NoError(t, err) + assert.Equal(t, caFP, them.Issuer) + + // No address is a caller that forgot the parameter, and is refused rather than handed + // this node's identity to authorize against; whoami is the command for that. + status, body = test.HTTPGet(t, path, "/v1/identity") + assert.Equal(t, http.StatusBadRequest, status, body) + assert.Contains(t, body, "whoami") + + status, body = test.HTTPGet(t, path, "/v1/whoami") + require.Equal(t, http.StatusOK, status, body) + var me identity + require.NoError(t, json.Unmarshal([]byte(body), &me)) + assert.Equal(t, "me", me.Name) + assert.Equal(t, []netip.Addr{myVpnIpNet[0].Addr()}, me.VpnAddrs) + + // An empty address is a caller's bug, and is told so rather than handed our identity. + status, body = test.HTTPGet(t, path, "/v1/identity?address=") + assert.Equal(t, http.StatusBadRequest, status) + assert.JSONEq(t, `{"error":"The provided vpn addr could not be parsed: "}`, body) + + // Nothing else nebula serves is reachable here. + status, body = test.HTTPGet(t, path, "/v1/list-hostmap") + assert.Equal(t, http.StatusForbidden, status) + assert.Contains(t, body, "identity lookups only") + + r.RenderHostmaps("Final hostmaps", myControl, theirControl) +} + +// infoSocket returns a short socket path in a private directory, one level below it +// so that listen has a directory of its own to create. +func infoSocket(t *testing.T) string { + t.Helper() + return filepath.Join(test.ShortTempDir(t), "n", "info.sock") +} diff --git a/e2e/testdata/commands_output.golden b/e2e/testdata/commands_output.golden index 01f6e11a..3899f7d0 100644 --- a/e2e/testdata/commands_output.golden +++ b/e2e/testdata/commands_output.golden @@ -202,6 +202,195 @@ $ print-cert -pretty not-an-address } exit status 1 +$ whoami +name: output-golden +vpn addrs: 10.222.0.1 +networks: 10.222.0.1/24 +unsafe networks: +groups: +fingerprint: +issuer: +not before: