Skip to content

Commit e3240d0

Browse files
feat: add connections, a saved SSH connection manager
A personal address book for reaching SSH targets from the browser: saved external hosts (dialed straight through an SSRF guardian, no agent) and ShellHub devices, each with its own auth. Keys come from the vault or a one-off paste, and host keys are pinned on a trust-on-first-use basis. The target kind is fixed at creation. Team connections are a Cloud/Enterprise capability, surfaced as an upsell on the Community edition.
1 parent db056dd commit e3240d0

94 files changed

Lines changed: 7255 additions & 311 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/shellhub-io/shellhub/api
33
go 1.25.8
44

55
require (
6+
code.dny.dev/ssrf v0.2.0
67
github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08
78
github.com/getkin/kin-openapi v0.140.0
89
github.com/getsentry/sentry-go v0.47.0
@@ -117,6 +118,7 @@ require (
117118
go.opentelemetry.io/otel v1.41.0 // indirect
118119
go.opentelemetry.io/otel/metric v1.41.0 // indirect
119120
go.opentelemetry.io/otel/trace v1.41.0 // indirect
121+
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 // indirect
120122
golang.org/x/net v0.56.0 // indirect
121123
golang.org/x/sync v0.21.0 // indirect
122124
golang.org/x/sys v0.46.0 // indirect

api/go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
code.dny.dev/ssrf v0.2.0 h1:wCBP990rQQ1CYfRpW+YK1+8xhwUjv189AQ3WMo1jQaI=
2+
code.dny.dev/ssrf v0.2.0/go.mod h1:B+91l25OnyaLIeCx0WRJN5qfJ/4/ZTZxRXgm0lj/2w8=
13
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
24
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
35
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
@@ -335,6 +337,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
335337
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
336338
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
337339
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
340+
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 h1:yZNXmy+j/JpX19vZkVktWqAo7Gny4PBWYYK3zskGpx4=
341+
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
338342
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
339343
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
340344
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=

api/pkg/responses/known_host.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package responses
2+
3+
import "github.com/shellhub-io/shellhub/pkg/models"
4+
5+
// KnownHostScanResult is the presented host key plus its verification status,
6+
// returned by a host-key scan. It lives here (not in the service package) so the
7+
// generated Service mock can reference it without importing the service package,
8+
// which would form an import cycle with the service's own tests.
9+
type KnownHostScanResult struct {
10+
KeyType string `json:"key_type"`
11+
Fingerprint string `json:"fingerprint"`
12+
PublicKey string `json:"public_key"`
13+
Status models.KnownHostStatus `json:"status"`
14+
Stored *models.KnownHost `json:"stored"`
15+
}

api/routes/connection.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package routes
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"strconv"
7+
8+
"github.com/shellhub-io/shellhub/api/pkg/gateway"
9+
svc "github.com/shellhub-io/shellhub/api/services"
10+
"github.com/shellhub-io/shellhub/pkg/api/requests"
11+
)
12+
13+
const (
14+
CreateConnectionURL = "/connections"
15+
ListConnectionsURL = "/connections"
16+
GetConnectionURL = "/connections/:id"
17+
ConnectionStatusURL = "/connections/:id/status"
18+
UpdateConnectionURL = "/connections/:id"
19+
DeleteConnectionURL = "/connections/:id"
20+
)
21+
22+
const ParamConnectionID = "id"
23+
24+
func (h *Handler) CreateConnection(c gateway.Context) error {
25+
var req requests.ConnectionCreate
26+
if err := c.Bind(&req); err != nil {
27+
return err
28+
}
29+
30+
if err := c.Validate(&req); err != nil {
31+
return err
32+
}
33+
34+
// For external connections, probe at save time. A 422 lets the UI distinguish
35+
// a blocked target (not a permitted address) from an unreachable host (the
36+
// NAT/firewall hint + install-the-agent funnel). Force skips the probe.
37+
if req.Kind == "external" && !req.Force {
38+
reachable, err := h.service.ProbeReachability(c.Ctx(), &requests.ConnectionProbe{Host: req.Host, Port: req.Port})
39+
switch {
40+
case errors.Is(err, svc.ErrEgressBlocked):
41+
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "blocked"})
42+
case err != nil:
43+
return err
44+
case !reachable:
45+
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "unreachable"})
46+
}
47+
}
48+
49+
connection, err := h.service.CreateConnection(c.Ctx(), &req)
50+
if err != nil {
51+
return err
52+
}
53+
54+
return c.JSON(http.StatusCreated, connection)
55+
}
56+
57+
func (h *Handler) ListConnections(c gateway.Context) error {
58+
req := new(requests.ConnectionList)
59+
if err := c.Bind(req); err != nil {
60+
return err
61+
}
62+
63+
req.Paginator.Normalize()
64+
req.Sorter.Normalize()
65+
66+
if err := c.Validate(req); err != nil {
67+
return err
68+
}
69+
70+
connections, count, err := h.service.ListConnections(c.Ctx(), req)
71+
if err != nil {
72+
return err
73+
}
74+
75+
c.Response().Header().Set("X-Total-Count", strconv.Itoa(count))
76+
77+
return c.JSON(http.StatusOK, connections)
78+
}
79+
80+
func (h *Handler) GetConnection(c gateway.Context) error {
81+
var req requests.ConnectionGet
82+
if err := c.Bind(&req); err != nil {
83+
return err
84+
}
85+
86+
if err := c.Validate(&req); err != nil {
87+
return err
88+
}
89+
90+
connection, err := h.service.GetConnection(c.Ctx(), &req)
91+
if err != nil {
92+
return err
93+
}
94+
95+
return c.JSON(http.StatusOK, connection)
96+
}
97+
98+
func (h *Handler) ConnectionStatus(c gateway.Context) error {
99+
var req requests.ConnectionGet
100+
if err := c.Bind(&req); err != nil {
101+
return err
102+
}
103+
104+
if err := c.Validate(&req); err != nil {
105+
return err
106+
}
107+
108+
online, err := h.service.ConnectionStatus(c.Ctx(), &req)
109+
if err != nil {
110+
return err
111+
}
112+
113+
return c.JSON(http.StatusOK, map[string]bool{"online": online})
114+
}
115+
116+
func (h *Handler) UpdateConnection(c gateway.Context) error {
117+
var req requests.ConnectionUpdate
118+
if err := c.Bind(&req); err != nil {
119+
return err
120+
}
121+
122+
if err := c.Validate(&req); err != nil {
123+
return err
124+
}
125+
126+
connection, err := h.service.UpdateConnection(c.Ctx(), &req)
127+
if err != nil {
128+
return err
129+
}
130+
131+
return c.JSON(http.StatusOK, connection)
132+
}
133+
134+
func (h *Handler) DeleteConnection(c gateway.Context) error {
135+
var req requests.ConnectionDelete
136+
if err := c.Bind(&req); err != nil {
137+
return err
138+
}
139+
140+
if err := c.Validate(&req); err != nil {
141+
return err
142+
}
143+
144+
if err := h.service.DeleteConnection(c.Ctx(), &req); err != nil {
145+
return err
146+
}
147+
148+
return c.NoContent(http.StatusOK)
149+
}

api/routes/known_host.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package routes
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
7+
"github.com/shellhub-io/shellhub/api/pkg/gateway"
8+
svc "github.com/shellhub-io/shellhub/api/services"
9+
"github.com/shellhub-io/shellhub/pkg/api/requests"
10+
)
11+
12+
const (
13+
ScanKnownHostURL = "/connections/host-key/scan"
14+
AcceptKnownHostURL = "/connections/host-key/accept"
15+
GetKnownHostURL = "/connections/host-key"
16+
DeleteKnownHostURL = "/connections/host-key"
17+
)
18+
19+
func (h *Handler) ScanKnownHost(c gateway.Context) error {
20+
var req requests.KnownHostScan
21+
if err := c.Bind(&req); err != nil {
22+
return err
23+
}
24+
25+
if err := c.Validate(&req); err != nil {
26+
return err
27+
}
28+
29+
result, err := h.service.ScanKnownHost(c.Ctx(), &req)
30+
if err != nil {
31+
// Surface a target we can't reach/read as a 422 (not a 500), and let the
32+
// UI tell a blocked address apart from an unreachable host.
33+
switch {
34+
case errors.Is(err, svc.ErrEgressBlocked):
35+
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "blocked"})
36+
case errors.Is(err, svc.ErrKnownHostUnreachable):
37+
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "unreachable"})
38+
}
39+
40+
return err
41+
}
42+
43+
return c.JSON(http.StatusOK, result)
44+
}
45+
46+
func (h *Handler) AcceptKnownHost(c gateway.Context) error {
47+
var req requests.KnownHostAccept
48+
if err := c.Bind(&req); err != nil {
49+
return err
50+
}
51+
52+
if err := c.Validate(&req); err != nil {
53+
return err
54+
}
55+
56+
knownHost, err := h.service.AcceptKnownHost(c.Ctx(), &req)
57+
if err != nil {
58+
if errors.Is(err, svc.ErrKnownHostInvalidKey) {
59+
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid_key"})
60+
}
61+
62+
return err
63+
}
64+
65+
return c.JSON(http.StatusOK, knownHost)
66+
}
67+
68+
func (h *Handler) GetKnownHost(c gateway.Context) error {
69+
var req requests.KnownHostGet
70+
if err := c.Bind(&req); err != nil {
71+
return err
72+
}
73+
74+
if err := c.Validate(&req); err != nil {
75+
return err
76+
}
77+
78+
knownHost, err := h.service.GetKnownHost(c.Ctx(), &req)
79+
if err != nil {
80+
return err
81+
}
82+
83+
return c.JSON(http.StatusOK, knownHost)
84+
}
85+
86+
func (h *Handler) DeleteKnownHost(c gateway.Context) error {
87+
var req requests.KnownHostDelete
88+
if err := c.Bind(&req); err != nil {
89+
return err
90+
}
91+
92+
if err := c.Validate(&req); err != nil {
93+
return err
94+
}
95+
96+
if err := h.service.DeleteKnownHost(c.Ctx(), &req); err != nil {
97+
return err
98+
}
99+
100+
return c.NoContent(http.StatusOK)
101+
}

api/routes/routes.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@ func NewRouter(service services.Service, opts ...Option) *echo.Echo {
151151
publicAPI.PUT(SetDeviceCustomFieldURL, gateway.Handler(handler.SetDeviceCustomField), routesmiddleware.RequiresPermission(authorizer.DeviceCustomFieldUpdate))
152152
publicAPI.DELETE(DeleteDeviceCustomFieldURL, gateway.Handler(handler.DeleteDeviceCustomField), routesmiddleware.RequiresPermission(authorizer.DeviceCustomFieldUpdate))
153153

154+
publicAPI.GET(ListConnectionsURL, routesmiddleware.Authorize(gateway.Handler(handler.ListConnections)))
155+
publicAPI.GET(GetConnectionURL, routesmiddleware.Authorize(gateway.Handler(handler.GetConnection)))
156+
publicAPI.GET(ConnectionStatusURL, routesmiddleware.Authorize(gateway.Handler(handler.ConnectionStatus)))
157+
publicAPI.POST(CreateConnectionURL, gateway.Handler(handler.CreateConnection), routesmiddleware.RequiresPermission(authorizer.ConnectionCreate))
158+
publicAPI.PUT(UpdateConnectionURL, gateway.Handler(handler.UpdateConnection), routesmiddleware.RequiresPermission(authorizer.ConnectionUpdate))
159+
publicAPI.DELETE(DeleteConnectionURL, gateway.Handler(handler.DeleteConnection), routesmiddleware.RequiresPermission(authorizer.ConnectionDelete))
160+
161+
publicAPI.POST(ScanKnownHostURL, routesmiddleware.Authorize(gateway.Handler(handler.ScanKnownHost)))
162+
publicAPI.POST(AcceptKnownHostURL, routesmiddleware.Authorize(gateway.Handler(handler.AcceptKnownHost)))
163+
publicAPI.GET(GetKnownHostURL, routesmiddleware.Authorize(gateway.Handler(handler.GetKnownHost)))
164+
publicAPI.DELETE(DeleteKnownHostURL, routesmiddleware.Authorize(gateway.Handler(handler.DeleteKnownHost)))
165+
154166
publicAPI.GET(URLGetTags, gateway.Handler(handler.GetTags))
155167
publicAPI.POST(URLCreateTag, gateway.Handler(handler.CreateTag), routesmiddleware.RequiresPermission(authorizer.TagCreate))
156168
publicAPI.PATCH(URLUpdateTag, gateway.Handler(handler.UpdateTag), routesmiddleware.RequiresPermission(authorizer.TagUpdate))

0 commit comments

Comments
 (0)