From c4b4edec35283db881f566a23f3f612d9ee27100 Mon Sep 17 00:00:00 2001 From: Henry Graham Date: Tue, 26 Aug 2025 12:05:03 -0500 Subject: [PATCH 1/4] info socket --- control.go | 4 ++ info.go | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 6 +++ 3 files changed, 119 insertions(+) create mode 100644 info.go diff --git a/control.go b/control.go index f8567b50..cd5ed219 100644 --- a/control.go +++ b/control.go @@ -35,6 +35,7 @@ type Control struct { dnsStart func() lighthouseStart func() connectionManagerStart func(context.Context) + infoStart func() } type ControlHostInfo struct { @@ -70,6 +71,9 @@ func (c *Control) Start() { if c.lighthouseStart != nil { c.lighthouseStart() } + if c.infoStart != nil { + go c.infoStart() + } // Start reading packets. c.f.run() diff --git a/info.go b/info.go new file mode 100644 index 00000000..6fc95728 --- /dev/null +++ b/info.go @@ -0,0 +1,109 @@ +package nebula + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/netip" + "time" + + "github.com/sirupsen/logrus" + "github.com/slackhq/nebula/config" +) + +func handleHostmapList(l *logrus.Logger, hm *HostMap, w http.ResponseWriter, r *http.Request) { + type HostListItem struct { + VpnAddrs []netip.Addr `json:"vpnAddrs"` + //Remote netip.AddrPort `json:"remote"` + Relayed bool `json:"relayed,omitempty"` + LastHandshakeTime time.Time `json:"lastHandshakeTime"` + Groups []string `json:"groups"` + } + + out := map[string]HostListItem{} + hm.ForEachVpnAddr(func(hi *HostInfo) { + cert := hi.GetCert().Certificate + out[cert.Name()] = HostListItem{ + VpnAddrs: hi.vpnAddrs, + //Remote: hi.remote, + Relayed: !hi.remote.IsValid(), + LastHandshakeTime: time.Unix(0, int64(hi.lastHandshakeTime)), + Groups: cert.Groups(), + } + }) + + w.Header().Set("Content-Type", "application/json") + js := json.NewEncoder(w) + err := js.Encode(out) + if err != nil { + http.Error(w, "json error: "+err.Error(), http.StatusInternalServerError) + return + } +} + +func handleHostCertLookup(l *logrus.Logger, hm *HostMap, w http.ResponseWriter, r *http.Request) { + ipStr := r.PathValue("ipStr") + if ipStr == "" { + http.Error(w, "you must provide an IP address", http.StatusNotFound) + return + } + + addr, err := netip.ParseAddr(ipStr) + if err != nil { + //todo filter non-Nebula IPs? + http.Error(w, fmt.Sprintf("Invalid IP address: %s", ipStr), http.StatusBadRequest) + return + } + hi := hm.QueryVpnAddr(addr) + if hi == nil { + http.Error(w, "IP address not found", http.StatusNotFound) + return + } else if hi.ConnectionState == nil { + http.Error(w, "Host not connected", http.StatusNotFound) + return + } + out, err := hi.ConnectionState.peerCert.Certificate.MarshalJSON() + if err != nil { + l.WithError(err).Error("failed to marshal peer certificate") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(out) +} + +func setupInfoServer(l *logrus.Logger, hm *HostMap) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /hostmap", func(w http.ResponseWriter, r *http.Request) { handleHostmapList(l, hm, w, r) }) + mux.HandleFunc("GET /host/{ipStr}", func(w http.ResponseWriter, r *http.Request) { handleHostCertLookup(l, hm, w, r) }) + return mux +} + +// startInfo stands up a REST API that serves information about what Nebula is doing to other services +// Right now, this is just hostmap info, +func startInfo(l *logrus.Logger, c *config.C, configTest bool, hm *HostMap) (func(), error) { + listen := c.GetString("info.listen", "") //todo this should probably refuse non-localhost, right? + if listen == "" { + return nil, nil + } + + var startFn func() + if configTest { + return startFn, nil + } + + startFn = func() { + mux := setupInfoServer(l, hm) + l.WithField("bind", listen).Info("Info listener starting") + err := http.ListenAndServe(listen, mux) + if errors.Is(err, http.ErrServerClosed) { + return + } + if err != nil { + l.Fatal(err) + } + } + + return startFn, nil +} diff --git a/main.go b/main.go index eb296fb0..51208a5a 100644 --- a/main.go +++ b/main.go @@ -268,6 +268,11 @@ func Main(c *config.C, configTest bool, buildVersion string, logger *logrus.Logg return nil, util.ContextualizeIfNeeded("Failed to start stats emitter", err) } + infoStart, err := startInfo(l, c, configTest, hostMap) + if err != nil { + return nil, util.ContextualizeIfNeeded("Failed to start info socket", err) + } + if configTest { return nil, nil } @@ -293,5 +298,6 @@ func Main(c *config.C, configTest bool, buildVersion string, logger *logrus.Logg dnsStart, lightHouse.StartUpdateWorker, connManager.Start, + infoStart, }, nil } From b7726b8a70b6e00a2d638e56e9964a1f1ff458cb Mon Sep 17 00:00:00 2001 From: Henry Graham Date: Thu, 25 Sep 2025 11:03:36 -0500 Subject: [PATCH 2/4] implemented warn on non-localhost IPs --- info.go | 20 ++++++++++++++++-- info_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 info_test.go diff --git a/info.go b/info.go index 6fc95728..619a6749 100644 --- a/info.go +++ b/info.go @@ -83,11 +83,17 @@ func setupInfoServer(l *logrus.Logger, hm *HostMap) *http.ServeMux { // startInfo stands up a REST API that serves information about what Nebula is doing to other services // Right now, this is just hostmap info, func startInfo(l *logrus.Logger, c *config.C, configTest bool, hm *HostMap) (func(), error) { - listen := c.GetString("info.listen", "") //todo this should probably refuse non-localhost, right? + listen := c.GetString("info.listen", "") if listen == "" { return nil, nil } - + addrPort, err := netip.ParseAddrPort(listen) + if err != nil { + return nil, fmt.Errorf("failed to parse info.listen address: %w", err) + } + if err = shouldAllowBinding(addrPort.Addr()); err != nil { + l.WithError(err).Warn("Specified info.listen address is not private") // TODO phrasing, what if we add non-nebula-ip check? + } var startFn func() if configTest { return startFn, nil @@ -107,3 +113,13 @@ func startInfo(l *logrus.Logger, c *config.C, configTest bool, hm *HostMap) (fun return startFn, nil } + +// https://github.com/slackhq/nebula/pull/1457#issuecomment-3275781278 +// > Refusing to bind to (non-localhost || non-nebula-ip) feels right to me +// If in the future we want to check for a non-nebula-ip we can add that check in here +func shouldAllowBinding(listen netip.Addr) error { + if !listen.IsLoopback() { + return fmt.Errorf("info.listen is not a loopback address: %s", listen.String()) + } + return nil +} diff --git a/info_test.go b/info_test.go new file mode 100644 index 00000000..00912b91 --- /dev/null +++ b/info_test.go @@ -0,0 +1,59 @@ +package nebula + +import ( + "github.com/stretchr/testify/assert" + "net/netip" + "testing" +) + +func TestInfo_shouldAllowBinding(t *testing.T) { + + tests := []struct { + name string + addr netip.Addr + shouldPass bool + }{ + { + name: "Allow binding to local IPv4", + addr: netip.MustParseAddr("127.0.0.1"), + shouldPass: true, + }, + { + name: "Allow binding to local IPv6", + addr: netip.MustParseAddr("::1"), + shouldPass: true, + }, + { + name: "Error binding to private IPv4", + addr: netip.MustParseAddr("192.168.1.1"), + shouldPass: false, + }, + { + name: "Error binding to private IPv6", + addr: netip.MustParseAddr("fd00::1"), + shouldPass: false, + }, + { + name: "Error binding to public IPv4", + addr: netip.MustParseAddr("1.1.1.1"), + shouldPass: false, + }, + { // Some random unallocated IPv6 address + name: "Error binding to public IPv6", + addr: netip.MustParseAddr("0cbb:c1ed:6a53:ca6b:f69f:8842:1ace:9ec0"), + shouldPass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := shouldAllowBinding(tt.addr) + + if tt.shouldPass { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} From 49eeee7f8c57ad1a2c33e4bea2dbff861e859bcb Mon Sep 17 00:00:00 2001 From: Henry Graham Date: Mon, 6 Oct 2025 19:59:22 -0500 Subject: [PATCH 3/4] Rough draft of what reworking this might look like. --- info.go | 148 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 88 insertions(+), 60 deletions(-) diff --git a/info.go b/info.go index 619a6749..594a5099 100644 --- a/info.go +++ b/info.go @@ -2,17 +2,29 @@ package nebula import ( "encoding/json" - "errors" "fmt" - "net/http" + "log" + "net" "net/netip" + "os" "time" "github.com/sirupsen/logrus" "github.com/slackhq/nebula/config" ) -func handleHostmapList(l *logrus.Logger, hm *HostMap, w http.ResponseWriter, r *http.Request) { +// TODO Firm up how we return errors and accept messages + data + +type Message struct { + Command string `json:"command"` + Data string `json:"data"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +func handleHostmapList(l *logrus.Logger, hm *HostMap) ([]byte, error) { type HostListItem struct { VpnAddrs []netip.Addr `json:"vpnAddrs"` //Remote netip.AddrPort `json:"remote"` @@ -20,7 +32,6 @@ func handleHostmapList(l *logrus.Logger, hm *HostMap, w http.ResponseWriter, r * LastHandshakeTime time.Time `json:"lastHandshakeTime"` Groups []string `json:"groups"` } - out := map[string]HostListItem{} hm.ForEachVpnAddr(func(hi *HostInfo) { cert := hi.GetCert().Certificate @@ -32,94 +43,111 @@ func handleHostmapList(l *logrus.Logger, hm *HostMap, w http.ResponseWriter, r * Groups: cert.Groups(), } }) - - w.Header().Set("Content-Type", "application/json") - js := json.NewEncoder(w) - err := js.Encode(out) + js, err := json.Marshal(out) if err != nil { - http.Error(w, "json error: "+err.Error(), http.StatusInternalServerError) - return + return nil, fmt.Errorf("json error: %w", err) } + return js, nil } -func handleHostCertLookup(l *logrus.Logger, hm *HostMap, w http.ResponseWriter, r *http.Request) { - ipStr := r.PathValue("ipStr") +func handleHostCertLookup(l *logrus.Logger, hm *HostMap, msg *Message) ([]byte, error) { + ipStr := msg.Data //TODO how do we want to structure this? What if we expand to more ssh commands? if ipStr == "" { - http.Error(w, "you must provide an IP address", http.StatusNotFound) - return + return nil, fmt.Errorf("you must provide an IP address") } - addr, err := netip.ParseAddr(ipStr) if err != nil { //todo filter non-Nebula IPs? - http.Error(w, fmt.Sprintf("Invalid IP address: %s", ipStr), http.StatusBadRequest) - return + return nil, fmt.Errorf("invalid IP address: %s", ipStr) } hi := hm.QueryVpnAddr(addr) if hi == nil { - http.Error(w, "IP address not found", http.StatusNotFound) - return + return nil, fmt.Errorf("ip address not found: %s", ipStr) } else if hi.ConnectionState == nil { - http.Error(w, "Host not connected", http.StatusNotFound) - return + return nil, fmt.Errorf("host not connected: %s", ipStr) } out, err := hi.ConnectionState.peerCert.Certificate.MarshalJSON() if err != nil { l.WithError(err).Error("failed to marshal peer certificate") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return + return nil, fmt.Errorf("failed to marshal peer certificate: %w", err) } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(out) + return out, nil } -func setupInfoServer(l *logrus.Logger, hm *HostMap) *http.ServeMux { - mux := http.NewServeMux() - mux.HandleFunc("GET /hostmap", func(w http.ResponseWriter, r *http.Request) { handleHostmapList(l, hm, w, r) }) - mux.HandleFunc("GET /host/{ipStr}", func(w http.ResponseWriter, r *http.Request) { handleHostCertLookup(l, hm, w, r) }) - return mux -} - -// startInfo stands up a REST API that serves information about what Nebula is doing to other services -// Right now, this is just hostmap info, func startInfo(l *logrus.Logger, c *config.C, configTest bool, hm *HostMap) (func(), error) { - listen := c.GetString("info.listen", "") - if listen == "" { - return nil, nil - } - addrPort, err := netip.ParseAddrPort(listen) - if err != nil { - return nil, fmt.Errorf("failed to parse info.listen address: %w", err) - } - if err = shouldAllowBinding(addrPort.Addr()); err != nil { - l.WithError(err).Warn("Specified info.listen address is not private") // TODO phrasing, what if we add non-nebula-ip check? - } + listenAddr := c.GetString("info.listen", "") var startFn func() if configTest { + //TODO validate that lisstenAddr is an acceptable value as part of the config test return startFn, nil } - + if err := os.RemoveAll(listenAddr); err != nil { + l.WithError(err).Fatal("failed to remove unix socket") + } startFn = func() { - mux := setupInfoServer(l, hm) - l.WithField("bind", listen).Info("Info listener starting") - err := http.ListenAndServe(listen, mux) - if errors.Is(err, http.ErrServerClosed) { - return - } + listener, err := net.Listen("unix", listenAddr) if err != nil { - l.Fatal(err) + log.Fatalf("Failed to listen on unix socket: %v", err) + } + defer listener.Close() + defer os.Remove(listenAddr) + l.WithField("bind", listenAddr).Info("Info listener starting") + for { + conn, err := listener.Accept() + if err != nil { + log.Printf("Failed to accept connection: %v", err) + continue + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 1024) // Arbitrary + n, err := c.Read(buf) + if err != nil { + l.WithError(err).Error("Failed to read from connection") + return + } + var msg Message + if err := json.Unmarshal(buf[:n], &msg); err != nil { + l.WithError(err).Error("Failed to unmarshal JSON") + return + } + l.WithField("command", msg.Command).WithField("Data", msg.Data).Debug("Received Command") + err = handleCommand(l, c, hm, &msg) + if err != nil { + l.WithError(err).Error("Failed to handle command") + out, err := json.Marshal(ErrorResponse{Error: err.Error()}) + if err != nil { + l.WithError(err).Error("Failed to marshal error response") + return + } + c.Write(out) + return + } + }(conn) } } - return startFn, nil } -// https://github.com/slackhq/nebula/pull/1457#issuecomment-3275781278 -// > Refusing to bind to (non-localhost || non-nebula-ip) feels right to me -// If in the future we want to check for a non-nebula-ip we can add that check in here -func shouldAllowBinding(listen netip.Addr) error { - if !listen.IsLoopback() { - return fmt.Errorf("info.listen is not a loopback address: %s", listen.String()) +// maybe we can add more of the supported SSH commands here? +func handleCommand(l *logrus.Logger, c net.Conn, hm *HostMap, msg *Message) error { + switch msg.Command { + case "ping": // TODO remove test command + c.Write([]byte("pong\n")) + case "hostmap": + out, err := handleHostmapList(l, hm) + if err != nil { + return err + } + c.Write(out) + case "hostinfo": + out, err := handleHostCertLookup(l, hm, msg) + if err != nil { + return err + } + c.Write(out) + default: + c.Write([]byte("unknown command\n")) } return nil } From 150484cc7adb3a66273a06422c428eaad7017687 Mon Sep 17 00:00:00 2001 From: Henry Graham Date: Mon, 6 Oct 2025 20:05:17 -0500 Subject: [PATCH 4/4] remove old tests --- info.go | 2 +- info_test.go | 59 ---------------------------------------------------- 2 files changed, 1 insertion(+), 60 deletions(-) delete mode 100644 info_test.go diff --git a/info.go b/info.go index 594a5099..1ccba14b 100644 --- a/info.go +++ b/info.go @@ -78,7 +78,7 @@ func startInfo(l *logrus.Logger, c *config.C, configTest bool, hm *HostMap) (fun listenAddr := c.GetString("info.listen", "") var startFn func() if configTest { - //TODO validate that lisstenAddr is an acceptable value as part of the config test + //TODO validate that listenAddr is an acceptable value as part of the config test return startFn, nil } if err := os.RemoveAll(listenAddr); err != nil { diff --git a/info_test.go b/info_test.go deleted file mode 100644 index 00912b91..00000000 --- a/info_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package nebula - -import ( - "github.com/stretchr/testify/assert" - "net/netip" - "testing" -) - -func TestInfo_shouldAllowBinding(t *testing.T) { - - tests := []struct { - name string - addr netip.Addr - shouldPass bool - }{ - { - name: "Allow binding to local IPv4", - addr: netip.MustParseAddr("127.0.0.1"), - shouldPass: true, - }, - { - name: "Allow binding to local IPv6", - addr: netip.MustParseAddr("::1"), - shouldPass: true, - }, - { - name: "Error binding to private IPv4", - addr: netip.MustParseAddr("192.168.1.1"), - shouldPass: false, - }, - { - name: "Error binding to private IPv6", - addr: netip.MustParseAddr("fd00::1"), - shouldPass: false, - }, - { - name: "Error binding to public IPv4", - addr: netip.MustParseAddr("1.1.1.1"), - shouldPass: false, - }, - { // Some random unallocated IPv6 address - name: "Error binding to public IPv6", - addr: netip.MustParseAddr("0cbb:c1ed:6a53:ca6b:f69f:8842:1ace:9ec0"), - shouldPass: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := shouldAllowBinding(tt.addr) - - if tt.shouldPass { - assert.NoError(t, err) - } else { - assert.Error(t, err) - } - }) - } -}