Skip to content

Commit aaed9e5

Browse files
committed
feature(agent): add VPN capability
It adds to the ShellHub's Agent the capability to connect to a ShellHub's Enterprise service, which provides a virtual private network between devices registered into the same namespace. To enable it, the ShellHub's instance must support it, and the `SHELLHUB_VPN` environmental variable must be set to `TRUE` on the ShellHub Agent startup.
1 parent bb83619 commit aaed9e5

File tree

20 files changed

+870
-14
lines changed

20 files changed

+870
-14
lines changed

Diff for: agent/go.mod

+3
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ require (
4848
github.com/pkg/errors v0.9.1 // indirect
4949
github.com/pkg/sftp v1.13.5 // indirect
5050
github.com/sethvargo/go-envconfig v0.9.0 // indirect
51+
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect
5152
github.com/spf13/pflag v1.0.5 // indirect
53+
github.com/vishvananda/netlink v1.2.1-beta.2 // indirect
54+
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f // indirect
5255
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
5356
go.opentelemetry.io/otel v1.26.0 // indirect
5457
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 // indirect

Diff for: agent/go.sum

+9
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ github.com/shellhub-io/ssh v0.0.0-20230224143412-edd48dfd6eea h1:7tEI9nukSYZViCj
9898
github.com/shellhub-io/ssh v0.0.0-20230224143412-edd48dfd6eea/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
9999
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
100100
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
101+
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
102+
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
101103
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
102104
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
103105
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -118,6 +120,11 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC
118120
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
119121
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
120122
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
123+
github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
124+
github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
125+
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
126+
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f h1:p4VB7kIXpOQvVn1ZaTIVp+3vuYAXFe3OJEvjbUYJLaA=
127+
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
121128
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
122129
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
123130
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
@@ -159,6 +166,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
159166
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
160167
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
161168
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
169+
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
170+
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
162171
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
163172
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
164173
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

Diff for: agent/main.go

+35-11
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ func main() {
109109
"tenant_id": cfg.TenantID,
110110
"server_address": cfg.ServerAddress,
111111
"preferred_hostname": cfg.PreferredHostname,
112-
}).Info("Listening for connections")
112+
}).Info("Listening for SSH connections")
113113

114114
// Disable check update in development mode
115115
if AgentVersion != "latest" {
@@ -163,23 +163,47 @@ func main() {
163163
}()
164164
}
165165

166-
if err := ag.ListenSSH(ctx); err != nil {
167-
log.WithError(err).WithFields(log.Fields{
168-
"version": AgentVersion,
169-
"mode": mode,
170-
"tenant_id": cfg.TenantID,
171-
"server_address": cfg.ServerAddress,
172-
"preferred_hostname": cfg.PreferredHostname,
173-
}).Fatal("Failed to listen for SSH connections")
174-
}
166+
go func() {
167+
if err := ag.ListenSSH(ctx); err != nil {
168+
log.WithError(err).WithFields(log.Fields{
169+
"version": AgentVersion,
170+
"mode": mode,
171+
"tenant_id": cfg.TenantID,
172+
"server_address": cfg.ServerAddress,
173+
"preferred_hostname": cfg.PreferredHostname,
174+
}).Fatal("Failed to listen for SSH connections")
175+
}
176+
}()
177+
178+
go func() {
179+
if !cfg.VPN {
180+
log.Info("VPN is disable")
181+
182+
return
183+
}
184+
185+
log.Debug("VPN enabled")
186+
187+
for {
188+
log.Info("VPN connected")
189+
190+
if err := ag.ConnectVPN(ctx); err != nil {
191+
log.WithError(err).Error("Connect to VPN lost. Retrying in 10 seconds.")
192+
}
193+
194+
time.Sleep(10 * time.Second)
195+
}
196+
}()
197+
198+
<-ctx.Done()
175199

176200
log.WithFields(log.Fields{
177201
"version": AgentVersion,
178202
"mode": mode,
179203
"tenant_id": cfg.TenantID,
180204
"server_address": cfg.ServerAddress,
181205
"preferred_hostname": cfg.PreferredHostname,
182-
}).Info("Stopped listening for connections")
206+
}).Info("Agent Stopped")
183207
},
184208
}
185209

Diff for: api/services/errors.go

+10
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ var (
130130
ErrAPIKeyDuplicated = errors.New("APIKey duplicated", ErrLayer, ErrCodeDuplicated)
131131
ErrAuthForbidden = errors.New("user is authenticated but cannot access this resource", ErrLayer, ErrCodeForbidden)
132132
ErrRoleInvalid = errors.New("role is invalid", ErrLayer, ErrCodeForbidden)
133+
ErrNamespaceIPInvalid = errors.New("ip is invalid", ErrLayer, ErrCodeForbidden)
134+
ErrNamespaceIPNotPrivate = errors.New("ip is not a private address", ErrLayer, ErrCodeForbidden)
133135
)
134136

135137
func NewErrRoleInvalid() error {
@@ -471,3 +473,11 @@ func NewErrDeviceMaxDevicesReached(count int) error {
471473
func NewErrAuthForbidden() error {
472474
return NewErrForbidden(ErrAuthForbidden, nil)
473475
}
476+
477+
func NewErrNamespaceIPInvalid() error {
478+
return NewErrInvalid(ErrNamespaceIPInvalid, nil, nil)
479+
}
480+
481+
func NewErrNamespaceIPNotPrivate() error {
482+
return NewErrInvalid(ErrNamespaceIPNotPrivate, nil, nil)
483+
}

Diff for: api/services/namespace.go

+31
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package services
33
import (
44
"context"
55
"errors"
6+
"net"
67
"strings"
78

89
"github.com/shellhub-io/shellhub/api/store"
910
"github.com/shellhub-io/shellhub/api/store/mongo"
1011
"github.com/shellhub-io/shellhub/pkg/api/authorizer"
12+
"github.com/shellhub-io/shellhub/pkg/api/internalclient"
1113
req "github.com/shellhub-io/shellhub/pkg/api/internalclient"
1214
"github.com/shellhub-io/shellhub/pkg/api/requests"
1315
"github.com/shellhub-io/shellhub/pkg/clock"
@@ -205,6 +207,27 @@ func (s *service) EditNamespace(ctx context.Context, req *requests.NamespaceEdit
205207
ConnectionAnnouncement: req.Settings.ConnectionAnnouncement,
206208
}
207209

210+
if envs.IsEnterprise() {
211+
changes.VPNEnable = req.VPN.Enable
212+
213+
if req.VPN.Address != nil {
214+
address := *req.VPN.Address
215+
ip := net.IPv4(address[0], address[1], address[2], address[3])
216+
217+
if ip.IsLoopback() || ip.IsUnspecified() {
218+
return nil, NewErrNamespaceIPInvalid()
219+
}
220+
221+
if !ip.IsPrivate() {
222+
return nil, NewErrNamespaceIPNotPrivate()
223+
}
224+
225+
changes.VPNAddress = &address
226+
}
227+
228+
changes.VPNMask = req.VPN.Mask
229+
}
230+
208231
if err := s.store.NamespaceEdit(ctx, req.Tenant, changes); err != nil {
209232
switch {
210233
case errors.Is(err, store.ErrNoDocuments):
@@ -214,6 +237,14 @@ func (s *service) EditNamespace(ctx context.Context, req *requests.NamespaceEdit
214237
}
215238
}
216239

240+
if envs.IsEnterprise() {
241+
cli := s.client.(internalclient.Client)
242+
243+
if err := cli.VPNStopRouter(req.Tenant); err != nil {
244+
return nil, err
245+
}
246+
}
247+
217248
return s.store.NamespaceGet(ctx, req.Tenant, true)
218249
}
219250

Diff for: api/services/namespace_test.go

+5
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@ func TestEditNamespace(t *testing.T) {
827827
tenantID: "xxxxx",
828828
namespaceName: "newname",
829829
requiredMocks: func() {
830+
envMock.On("Get", "SHELLHUB_ENTERPRISE").Return("false").Once()
830831
mock.On("NamespaceEdit", ctx, "xxxxx", &models.NamespaceChanges{Name: "newname"}).
831832
Return(store.ErrNoDocuments).
832833
Once()
@@ -841,6 +842,7 @@ func TestEditNamespace(t *testing.T) {
841842
tenantID: "xxxxx",
842843
namespaceName: "newname",
843844
requiredMocks: func() {
845+
envMock.On("Get", "SHELLHUB_ENTERPRISE").Return("false").Once()
844846
mock.On("NamespaceEdit", ctx, "xxxxx", &models.NamespaceChanges{Name: "newname"}).
845847
Return(errors.New("error")).
846848
Once()
@@ -855,6 +857,7 @@ func TestEditNamespace(t *testing.T) {
855857
namespaceName: "newName",
856858
tenantID: "xxxxx",
857859
requiredMocks: func() {
860+
envMock.On("Get", "SHELLHUB_ENTERPRISE").Return("false").Once()
858861
mock.On("NamespaceEdit", ctx, "xxxxx", &models.NamespaceChanges{Name: "newname"}).
859862
Return(nil).
860863
Once()
@@ -881,6 +884,7 @@ func TestEditNamespace(t *testing.T) {
881884
namespaceName: "newname",
882885
tenantID: "xxxxx",
883886
requiredMocks: func() {
887+
envMock.On("Get", "SHELLHUB_ENTERPRISE").Return("false").Once()
884888
mock.On("NamespaceEdit", ctx, "xxxxx", &models.NamespaceChanges{Name: "newname"}).
885889
Return(nil).
886890
Once()
@@ -890,6 +894,7 @@ func TestEditNamespace(t *testing.T) {
890894
Name: "newname",
891895
}
892896

897+
envMock.On("Get", "SHELLHUB_ENTERPRISE").Return("false").Once()
893898
mock.On("NamespaceGet", ctx, "xxxxx", true).
894899
Return(namespace, nil).
895900
Once()

Diff for: docker-compose.agent.yml

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ services:
1919
- SHELLHUB_PRIVATE_KEY=/go/src/github.com/shellhub-io/shellhub/agent/shellhub.key
2020
- SHELLHUB_TENANT_ID=00000000-0000-4000-0000-000000000000
2121
- SHELLHUB_VERSION=${SHELLHUB_VERSION}
22+
- SHELLHUB_VPN=${SHELLHUB_VPN}
2223
- SHELLHUB_LOG_LEVEL=${SHELLHUB_LOG_LEVEL}
2324
- SHELLHUB_LOG_FORMAT=${SHELLHUB_LOG_FORMAT}
2425
volumes:

Diff for: go.mod

+3
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ require (
2727
github.com/pkg/sftp v1.13.5
2828
github.com/sethvargo/go-envconfig v0.9.0
2929
github.com/sirupsen/logrus v1.9.3
30+
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
3031
github.com/stretchr/testify v1.9.0
3132
github.com/testcontainers/testcontainers-go/modules/redis v0.32.0
33+
github.com/vishvananda/netlink v1.2.1-beta.2
3234
golang.org/x/crypto v0.22.0
3335
golang.org/x/sys v0.19.0
3436
)
@@ -96,6 +98,7 @@ require (
9698
github.com/ulikunitz/xz v0.5.11 // indirect
9799
github.com/valyala/bytebufferpool v1.0.0 // indirect
98100
github.com/valyala/fasttemplate v1.2.2 // indirect
101+
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f // indirect
99102
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
100103
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
101104
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect

Diff for: pkg/agent/agent.go

+11
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import (
5050
"github.com/shellhub-io/shellhub/pkg/agent/pkg/keygen"
5151
"github.com/shellhub-io/shellhub/pkg/agent/pkg/sysinfo"
5252
"github.com/shellhub-io/shellhub/pkg/agent/ssh"
53+
"github.com/shellhub-io/shellhub/pkg/agent/vpn"
5354
"github.com/shellhub-io/shellhub/pkg/api/client"
5455
"github.com/shellhub-io/shellhub/pkg/envs"
5556
"github.com/shellhub-io/shellhub/pkg/models"
@@ -114,6 +115,9 @@ type Config struct {
114115
// MaxRetryConnectionTimeout specifies the maximum time, in seconds, that an agent will wait
115116
// before attempting to reconnect to the ShellHub server. Default is 60 seconds.
116117
MaxRetryConnectionTimeout int `env:"MAX_RETRY_CONNECTION_TIMEOUT,default=60" validate:"min=10,max=120"`
118+
119+
// Defines if the device will try to connect to the namespace's VPN.
120+
VPN bool `env:"VPN,default=false"`
117121
}
118122

119123
func LoadConfigFromEnv() (*Config, map[string]interface{}, error) {
@@ -163,6 +167,7 @@ type Agent struct {
163167
serverInfo *models.Info
164168
cli client.Client
165169
ssh *ssh.SSH
170+
vpn *vpn.VPN
166171
mode Mode
167172
}
168173

@@ -357,6 +362,12 @@ func (a *Agent) ListenSSH(ctx context.Context) error {
357362
return a.ssh.Listen(ctx)
358363
}
359364

365+
func (a *Agent) ConnectVPN(ctx context.Context) error {
366+
a.vpn = vpn.NewVPN(a.cli, a.authData.Token)
367+
368+
return a.vpn.Connect(ctx)
369+
}
370+
360371
// CheckUpdate gets the ShellHub's server version.
361372
func (a *Agent) CheckUpdate() (*semver.Version, error) {
362373
info, err := a.cli.GetInfo(AgentVersion)

Diff for: pkg/agent/pkg/tunnel/tunnel.go

+32
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,38 @@ func NewTunnel() *Tunnel {
8383
return t
8484
}
8585

86+
const ContextKeyHTTPConn string = "http-conn"
87+
88+
// NewCustomTunnel creates a new [Tunnel] with the route to the connect, in a POST, and close, in a DELETE, actions.
89+
func NewCustomTunnel(connPath string, closePath string) *Tunnel {
90+
router := echo.New()
91+
92+
t := &Tunnel{
93+
router: router,
94+
srv: &http.Server{
95+
Handler: router,
96+
ConnContext: func(ctx context.Context, c net.Conn) context.Context {
97+
return context.WithValue(ctx, ContextKeyHTTPConn, c) //nolint:revive
98+
},
99+
},
100+
ConnHandler: func(e echo.Context) error {
101+
panic("connHandler can not be nil")
102+
},
103+
CloseHandler: func(e echo.Context) error {
104+
panic("closeHandler can not be nil")
105+
},
106+
}
107+
108+
router.POST(connPath, func(e echo.Context) error {
109+
return t.ConnHandler(e)
110+
})
111+
router.DELETE(closePath, func(e echo.Context) error {
112+
return t.CloseHandler(e)
113+
})
114+
115+
return t
116+
}
117+
86118
// Listen to reverse listener.
87119
func (t *Tunnel) Listen(l *revdial.Listener) error {
88120
return t.srv.Serve(l)

Diff for: pkg/agent/vpn/handlers.go

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package vpn
2+
3+
import (
4+
"net"
5+
6+
"github.com/labstack/echo/v4"
7+
log "github.com/sirupsen/logrus"
8+
)
9+
10+
func handler(handler func(net.Conn, *Settings) error) func(c echo.Context) error {
11+
return func(c echo.Context) error {
12+
log.Debug("handler started")
13+
defer log.Debug("handler done")
14+
15+
conn, _, err := c.Response().Hijack()
16+
if err != nil {
17+
log.Error(err)
18+
19+
return err
20+
}
21+
22+
defer conn.Close()
23+
24+
settings, err := ParseSettings(c.Request().Body)
25+
if err != nil {
26+
log.WithError(err).Error("faild to parse the settings")
27+
28+
return err
29+
}
30+
31+
// NOTE: the [handler] is called to handler the core logic of the VPN client, while this handler is used to extract
32+
// the connection and the settings data.
33+
if err := handler(conn, settings); err != nil {
34+
log.WithError(err).Error("failed to handler the vpn connection between server and agent")
35+
36+
return err
37+
}
38+
39+
return nil
40+
}
41+
}
42+
43+
func closeHandler(callback func() error) func(c echo.Context) error {
44+
return func(c echo.Context) error {
45+
log.Trace("close handler called")
46+
47+
return callback()
48+
}
49+
}

0 commit comments

Comments
 (0)