Skip to content

Commit 79e7ff6

Browse files
committed
feat: add SSH settings for device and namespace
Add allow-based SSH settings for both namespace and device configuration, update the backend migrations and auth flow, and wire the React console to edit the new settings consistently. Closes #6136
1 parent e786c11 commit 79e7ff6

63 files changed

Lines changed: 2518 additions & 285 deletions

Some content is hidden

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

agent/installer.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func registerInstallerCommands(rootCmd *cobra.Command) {
8888
installCmd.Flags().String("preferred-identity", "", "Preferred device identity")
8989
installCmd.Flags().Uint("keepalive-interval", 30, "Keepalive interval in seconds")
9090
installCmd.MarkFlagRequired("server-address") //nolint:errcheck
91-
installCmd.MarkFlagRequired("tenant-id") //nolint:errcheck
91+
installCmd.MarkFlagRequired("tenant-id") //nolint:errcheck
9292

9393
rootCmd.AddCommand(installCmd)
9494

@@ -169,7 +169,7 @@ func writeAgentEnvFile(cfg installerConfig) error {
169169
fmt.Fprintf(&buf, "SHELLHUB_KEEPALIVE_INTERVAL=%d\n", cfg.KeepaliveInterval)
170170
}
171171

172-
return os.WriteFile(agentEnvFile, buf.Bytes(), 0600)
172+
return os.WriteFile(agentEnvFile, buf.Bytes(), 0o600)
173173
}
174174

175175
func writeAgentServiceFile(binaryPath string) error {
@@ -183,7 +183,7 @@ func writeAgentServiceFile(binaryPath string) error {
183183
return err
184184
}
185185

186-
return os.WriteFile(agentServiceFile, buf.Bytes(), 0644)
186+
return os.WriteFile(agentServiceFile, buf.Bytes(), 0o644)
187187
}
188188

189189
func agentUninstall() error {

api/routes/device.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const (
2121
LookupDeviceURL = "/device/lookup"
2222
UpdateDeviceStatusURL = "/devices/:uid/:status"
2323
UpdateDevice = "/devices/:uid"
24+
GetDeviceSettingsURL = "/devices/:uid/settings"
25+
UpdateDeviceSettingsURL = "/devices/:uid/settings"
2426
SetDeviceCustomFieldURL = "/devices/:uid/custom_fields/:key"
2527
DeleteDeviceCustomFieldURL = "/devices/:uid/custom_fields/:key"
2628
)
@@ -263,13 +265,62 @@ func (h *Handler) UpdateDevice(c gateway.Context) error {
263265
return err
264266
}
265267

268+
if c.Tenant() != nil {
269+
req.TenantID = c.Tenant().ID
270+
}
271+
266272
if err := h.service.UpdateDevice(c.Ctx(), req); err != nil {
267273
return err
268274
}
269275

270276
return c.NoContent(http.StatusOK)
271277
}
272278

279+
func (h *Handler) GetDeviceSettings(c gateway.Context) error {
280+
req := new(requests.DeviceGetSettings)
281+
282+
if err := c.Bind(req); err != nil {
283+
return err
284+
}
285+
286+
if c.Tenant() != nil {
287+
req.TenantID = c.Tenant().ID
288+
}
289+
290+
if err := c.Validate(req); err != nil {
291+
return err
292+
}
293+
294+
settings, err := h.service.GetDeviceSettings(c.Ctx(), req)
295+
if err != nil {
296+
return err
297+
}
298+
299+
return c.JSON(http.StatusOK, settings)
300+
}
301+
302+
func (h *Handler) UpdateDeviceSettings(c gateway.Context) error {
303+
req := new(requests.DeviceUpdateSettings)
304+
305+
if err := c.Bind(req); err != nil {
306+
return err
307+
}
308+
309+
if c.Tenant() != nil {
310+
req.TenantID = c.Tenant().ID
311+
}
312+
313+
if err := c.Validate(req); err != nil {
314+
return err
315+
}
316+
317+
if err := h.service.UpdateDeviceSettings(c.Ctx(), req); err != nil {
318+
return err
319+
}
320+
321+
return c.NoContent(http.StatusOK)
322+
}
323+
273324
func (h *Handler) SetDeviceCustomField(c gateway.Context) error {
274325
req := new(requests.DeviceSetCustomField)
275326

api/routes/device_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,3 +662,110 @@ func TestUpdateDevice(t *testing.T) {
662662
})
663663
}
664664
}
665+
666+
func TestGetDeviceSettings(t *testing.T) {
667+
mock := new(mocks.Service)
668+
669+
settings := &models.SSHSettings{
670+
AllowPassword: false,
671+
AllowPublicKey: true,
672+
}
673+
674+
mock.
675+
On("GetDeviceSettings", gomock.Anything, &requests.DeviceGetSettings{
676+
TenantID: "00000000-0000-4000-0000-000000000000",
677+
DeviceParam: requests.DeviceParam{UID: "1234"},
678+
}).
679+
Return(settings, nil).
680+
Once()
681+
682+
req := httptest.NewRequest(http.MethodGet, "/api/devices/1234/settings", nil)
683+
req.Header.Set("Content-Type", "application/json")
684+
req.Header.Set("X-Role", authorizer.RoleOwner.String())
685+
req.Header.Set("X-Tenant-ID", "00000000-0000-4000-0000-000000000000")
686+
rec := httptest.NewRecorder()
687+
688+
e := NewRouter(mock)
689+
e.ServeHTTP(rec, req)
690+
691+
assert.Equal(t, http.StatusOK, rec.Result().StatusCode)
692+
693+
var body *models.SSHSettings
694+
err := json.NewDecoder(rec.Result().Body).Decode(&body)
695+
require.NoError(t, err)
696+
assert.Equal(t, settings, body)
697+
}
698+
699+
func TestUpdateDeviceSettings(t *testing.T) {
700+
mock := new(mocks.Service)
701+
702+
cases := []struct {
703+
description string
704+
req requests.DeviceUpdateSettings
705+
requiredMocks func()
706+
expectedStatus int
707+
}{
708+
{
709+
description: "fails when device settings update cannot find device",
710+
req: requests.DeviceUpdateSettings{
711+
TenantID: "00000000-0000-4000-0000-000000000000",
712+
DeviceParam: requests.DeviceParam{
713+
UID: "1234",
714+
},
715+
},
716+
requiredMocks: func() {
717+
mock.On("UpdateDeviceSettings", gomock.Anything, &requests.DeviceUpdateSettings{
718+
TenantID: "00000000-0000-4000-0000-000000000000",
719+
DeviceParam: requests.DeviceParam{UID: "1234"},
720+
}).Return(svc.ErrNotFound).Once()
721+
},
722+
expectedStatus: http.StatusNotFound,
723+
},
724+
{
725+
description: "success when updating a device setting",
726+
req: requests.DeviceUpdateSettings{
727+
TenantID: "00000000-0000-4000-0000-000000000000",
728+
DeviceParam: requests.DeviceParam{
729+
UID: "1234",
730+
},
731+
SSHSettingsUpdate: requests.SSHSettingsUpdate{
732+
AllowPassword: func() *bool {
733+
v := false
734+
return &v
735+
}(),
736+
},
737+
},
738+
requiredMocks: func() {
739+
v := false
740+
mock.On("UpdateDeviceSettings", gomock.Anything, &requests.DeviceUpdateSettings{
741+
TenantID: "00000000-0000-4000-0000-000000000000",
742+
DeviceParam: requests.DeviceParam{UID: "1234"},
743+
SSHSettingsUpdate: requests.SSHSettingsUpdate{
744+
AllowPassword: &v,
745+
},
746+
}).Return(nil).Once()
747+
},
748+
expectedStatus: http.StatusOK,
749+
},
750+
}
751+
752+
for _, tc := range cases {
753+
t.Run(tc.description, func(t *testing.T) {
754+
tc.requiredMocks()
755+
756+
jsonData, err := json.Marshal(tc.req)
757+
require.NoError(t, err)
758+
759+
req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/devices/%s/settings", tc.req.UID), strings.NewReader(string(jsonData)))
760+
req.Header.Set("Content-Type", "application/json")
761+
req.Header.Set("X-Role", authorizer.RoleOwner.String())
762+
req.Header.Set("X-Tenant-ID", "00000000-0000-4000-0000-000000000000")
763+
rec := httptest.NewRecorder()
764+
765+
e := NewRouter(mock)
766+
e.ServeHTTP(rec, req)
767+
768+
assert.Equal(t, tc.expectedStatus, rec.Result().StatusCode)
769+
})
770+
}
771+
}

api/routes/routes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,10 @@ func NewRouter(service services.Service, opts ...Option) *echo.Echo {
143143

144144
publicAPI.GET(GetDeviceListURL, routesmiddleware.Authorize(gateway.Handler(handler.GetDeviceList)))
145145
publicAPI.GET(GetDeviceURL, routesmiddleware.Authorize(gateway.Handler(handler.GetDevice)))
146+
publicAPI.GET(GetDeviceSettingsURL, routesmiddleware.Authorize(gateway.Handler(handler.GetDeviceSettings)))
146147
publicAPI.GET(ResolveDeviceURL, routesmiddleware.Authorize(gateway.Handler(handler.ResolveDevice)))
147148
publicAPI.PUT(UpdateDevice, gateway.Handler(handler.UpdateDevice), routesmiddleware.RequiresPermission(authorizer.DeviceUpdate))
149+
publicAPI.PATCH(UpdateDeviceSettingsURL, gateway.Handler(handler.UpdateDeviceSettings), routesmiddleware.RequiresPermission(authorizer.DeviceUpdate))
148150
publicAPI.PATCH(RenameDeviceURL, gateway.Handler(handler.RenameDevice), routesmiddleware.RequiresPermission(authorizer.DeviceRename))
149151
publicAPI.PATCH(UpdateDeviceStatusURL, gateway.Handler(handler.UpdateDeviceStatus), routesmiddleware.RequiresPermission(authorizer.DeviceAccept)) // TODO: DeviceWrite
150152
publicAPI.DELETE(DeleteDeviceURL, gateway.Handler(handler.DeleteDevice), routesmiddleware.RequiresPermission(authorizer.DeviceRemove))

api/services/device.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ type DeviceService interface {
6262
OfflineDevice(ctx context.Context, uid models.UID) error
6363

6464
UpdateDevice(ctx context.Context, req *requests.DeviceUpdate) error
65+
GetDeviceSettings(ctx context.Context, req *requests.DeviceGetSettings) (*models.SSHSettings, error)
66+
UpdateDeviceSettings(ctx context.Context, req *requests.DeviceUpdateSettings) error
6567
// UpdateDeviceStatus updates a device's status. Devices that are already accepted cannot change their status.
6668
//
6769
// When accepting, if a device with the same MAC address is already accepted within the same namespace, it
@@ -398,6 +400,64 @@ func (s *service) UpdateDevice(ctx context.Context, req *requests.DeviceUpdate)
398400
return nil
399401
}
400402

403+
func (s *service) GetDeviceSettings(ctx context.Context, req *requests.DeviceGetSettings) (*models.SSHSettings, error) {
404+
device, err := s.store.DeviceResolve(ctx, store.DeviceUIDResolver, req.UID, s.store.Options().InNamespace(req.TenantID))
405+
if err != nil {
406+
return nil, NewErrDeviceNotFound(models.UID(req.UID), err)
407+
}
408+
409+
if device.SSH == nil {
410+
return models.DefaultSSHSettings(), nil
411+
}
412+
413+
return device.SSH, nil
414+
}
415+
416+
func (s *service) UpdateDeviceSettings(ctx context.Context, req *requests.DeviceUpdateSettings) error {
417+
device, err := s.store.DeviceResolve(ctx, store.DeviceUIDResolver, req.UID, s.store.Options().InNamespace(req.TenantID))
418+
if err != nil {
419+
return NewErrDeviceNotFound(models.UID(req.UID), err)
420+
}
421+
422+
if device.SSH == nil {
423+
device.SSH = models.DefaultSSHSettings()
424+
}
425+
426+
if req.AllowPassword != nil {
427+
device.SSH.AllowPassword = *req.AllowPassword
428+
}
429+
if req.AllowPublicKey != nil {
430+
device.SSH.AllowPublicKey = *req.AllowPublicKey
431+
}
432+
if req.AllowRoot != nil {
433+
device.SSH.AllowRoot = *req.AllowRoot
434+
}
435+
if req.AllowEmptyPasswords != nil {
436+
device.SSH.AllowEmptyPasswords = *req.AllowEmptyPasswords
437+
}
438+
if req.AllowTTY != nil {
439+
device.SSH.AllowTTY = *req.AllowTTY
440+
}
441+
if req.AllowTCPForwarding != nil {
442+
device.SSH.AllowTCPForwarding = *req.AllowTCPForwarding
443+
}
444+
if req.AllowWebEndpoints != nil {
445+
device.SSH.AllowWebEndpoints = *req.AllowWebEndpoints
446+
}
447+
if req.AllowSFTP != nil {
448+
device.SSH.AllowSFTP = *req.AllowSFTP
449+
}
450+
if req.AllowAgentForwarding != nil {
451+
device.SSH.AllowAgentForwarding = *req.AllowAgentForwarding
452+
}
453+
454+
if err := s.store.DeviceUpdateSettings(ctx, req.UID, device.SSH); err != nil {
455+
return err
456+
}
457+
458+
return nil
459+
}
460+
401461
// maxCustomFieldsPerDevice is the upper bound on the number of custom_fields entries
402462
// per device. Enforced server-side to prevent storage abuse.
403463
const maxCustomFieldsPerDevice = 20
@@ -451,6 +511,10 @@ func (s *service) mergeDevice(ctx context.Context, tenantID string, oldDevice *m
451511
}
452512

453513
log.WithFields(logFields).Debug("updating new device name to preserve old device identity")
514+
if oldDevice.SSH != nil {
515+
newDevice.SSH = oldDevice.SSH
516+
}
517+
454518
newDevice.Name = oldDevice.Name
455519
if err := s.store.DeviceUpdate(ctx, newDevice); err != nil {
456520
log.WithError(err).WithFields(logFields).Error("failed to update new device name")

0 commit comments

Comments
 (0)