diff --git a/agent/installer.go b/agent/installer.go index fbd63a59d8b..84a8c2c4f5c 100644 --- a/agent/installer.go +++ b/agent/installer.go @@ -88,7 +88,7 @@ func registerInstallerCommands(rootCmd *cobra.Command) { installCmd.Flags().String("preferred-identity", "", "Preferred device identity") installCmd.Flags().Uint("keepalive-interval", 30, "Keepalive interval in seconds") installCmd.MarkFlagRequired("server-address") //nolint:errcheck - installCmd.MarkFlagRequired("tenant-id") //nolint:errcheck + installCmd.MarkFlagRequired("tenant-id") //nolint:errcheck rootCmd.AddCommand(installCmd) @@ -169,7 +169,7 @@ func writeAgentEnvFile(cfg installerConfig) error { fmt.Fprintf(&buf, "SHELLHUB_KEEPALIVE_INTERVAL=%d\n", cfg.KeepaliveInterval) } - return os.WriteFile(agentEnvFile, buf.Bytes(), 0600) + return os.WriteFile(agentEnvFile, buf.Bytes(), 0o600) } func writeAgentServiceFile(binaryPath string) error { @@ -183,7 +183,7 @@ func writeAgentServiceFile(binaryPath string) error { return err } - return os.WriteFile(agentServiceFile, buf.Bytes(), 0644) + return os.WriteFile(agentServiceFile, buf.Bytes(), 0o644) } func agentUninstall() error { diff --git a/api/routes/device.go b/api/routes/device.go index a7d1bda347f..b8654df436d 100644 --- a/api/routes/device.go +++ b/api/routes/device.go @@ -251,6 +251,10 @@ func (h *Handler) UpdateDevice(c gateway.Context) error { return err } + if c.Tenant() != nil { + req.TenantID = c.Tenant().ID + } + if err := h.service.UpdateDevice(c.Ctx(), req); err != nil { return err } diff --git a/api/services/device.go b/api/services/device.go index 1cb608277e7..f58a3130597 100644 --- a/api/services/device.go +++ b/api/services/device.go @@ -339,14 +339,51 @@ func (s *service) UpdateDevice(ctx context.Context, req *requests.DeviceUpdate) return NewErrDeviceNotFound(models.UID(req.UID), err) } - conflictsTarget := &models.DeviceConflicts{Name: req.Name} - conflictsTarget.Distinct(device) - if _, has, err := s.store.DeviceConflicts(ctx, conflictsTarget); err != nil || has { - return NewErrDeviceDuplicated(req.Name, err) + if req.Name != "" { + conflictsTarget := &models.DeviceConflicts{Name: req.Name} + conflictsTarget.Distinct(device) + if _, has, err := s.store.DeviceConflicts(ctx, conflictsTarget); err != nil || has { + return NewErrDeviceDuplicated(req.Name, err) + } + + if !strings.EqualFold(req.Name, device.Name) { + device.Name = strings.ToLower(req.Name) + } } - if req.Name != "" && !strings.EqualFold(req.Name, device.Name) { - device.Name = strings.ToLower(req.Name) + if req.SSH != nil { + // Only initialize SSH if device doesn't have one and we're updating it + if device.SSH == nil { + device.SSH = models.DefaultSSHSettings() + } + + if req.SSH.AllowPassword != nil { + device.SSH.AllowPassword = *req.SSH.AllowPassword + } + if req.SSH.AllowPublicKey != nil { + device.SSH.AllowPublicKey = *req.SSH.AllowPublicKey + } + if req.SSH.AllowRoot != nil { + device.SSH.AllowRoot = *req.SSH.AllowRoot + } + if req.SSH.AllowEmptyPasswords != nil { + device.SSH.AllowEmptyPasswords = *req.SSH.AllowEmptyPasswords + } + if req.SSH.AllowTTY != nil { + device.SSH.AllowTTY = *req.SSH.AllowTTY + } + if req.SSH.AllowTCPForwarding != nil { + device.SSH.AllowTCPForwarding = *req.SSH.AllowTCPForwarding + } + if req.SSH.AllowWebEndpoints != nil { + device.SSH.AllowWebEndpoints = *req.SSH.AllowWebEndpoints + } + if req.SSH.AllowSFTP != nil { + device.SSH.AllowSFTP = *req.SSH.AllowSFTP + } + if req.SSH.AllowAgentForwarding != nil { + device.SSH.AllowAgentForwarding = *req.SSH.AllowAgentForwarding + } } if err := s.store.DeviceUpdate(ctx, device); err != nil { // nolint:revive @@ -376,6 +413,10 @@ func (s *service) mergeDevice(ctx context.Context, tenantID string, oldDevice *m } log.WithFields(logFields).Debug("updating new device name to preserve old device identity") + if oldDevice.SSH != nil { + newDevice.SSH = oldDevice.SSH + } + newDevice.Name = oldDevice.Name if err := s.store.DeviceUpdate(ctx, newDevice); err != nil { log.WithError(err).WithFields(logFields).Error("failed to update new device name") diff --git a/api/services/device_test.go b/api/services/device_test.go index 769e8d61778..2bc9a89bad9 100644 --- a/api/services/device_test.go +++ b/api/services/device_test.go @@ -1699,6 +1699,17 @@ func TestUpdateDeviceStatus(t *testing.T) { TenantID: "00000000-0000-0000-0000-000000000000", Status: models.DeviceStatusAccepted, Identity: &models.DeviceIdentity{MAC: "aa:bb:cc:dd:ee:ff"}, + SSH: &models.SSHSettings{ + AllowPassword: false, + AllowPublicKey: true, + AllowRoot: false, + AllowEmptyPasswords: true, + AllowTTY: false, + AllowTCPForwarding: true, + AllowWebEndpoints: false, + AllowSFTP: true, + AllowAgentForwarding: false, + }, } mergedDevice := &models.Device{ UID: "new-device", @@ -1706,6 +1717,17 @@ func TestUpdateDeviceStatus(t *testing.T) { TenantID: "00000000-0000-0000-0000-000000000000", Status: models.DeviceStatusPending, Identity: &models.DeviceIdentity{MAC: "aa:bb:cc:dd:ee:ff"}, + SSH: &models.SSHSettings{ + AllowPassword: false, + AllowPublicKey: true, + AllowRoot: false, + AllowEmptyPasswords: true, + AllowTTY: false, + AllowTCPForwarding: true, + AllowWebEndpoints: false, + AllowSFTP: true, + AllowAgentForwarding: false, + }, } finalDevice := &models.Device{ UID: "new-device", @@ -1714,6 +1736,17 @@ func TestUpdateDeviceStatus(t *testing.T) { Status: models.DeviceStatusAccepted, StatusUpdatedAt: now, Identity: &models.DeviceIdentity{MAC: "aa:bb:cc:dd:ee:ff"}, + SSH: &models.SSHSettings{ + AllowPassword: false, + AllowPublicKey: true, + AllowRoot: false, + AllowEmptyPasswords: true, + AllowTTY: false, + AllowTCPForwarding: true, + AllowWebEndpoints: false, + AllowSFTP: true, + AllowAgentForwarding: false, + }, } storeMock. diff --git a/api/services/namespace.go b/api/services/namespace.go index d9aef918714..2a39a5ed778 100644 --- a/api/services/namespace.go +++ b/api/services/namespace.go @@ -66,6 +66,15 @@ func (s *service) CreateNamespace(ctx context.Context, req *requests.NamespaceCr Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: "", + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, TenantID: req.TenantID, Type: models.NewDefaultType(), @@ -176,6 +185,42 @@ func (s *service) EditNamespace(ctx context.Context, req *requests.NamespaceEdit namespace.Settings.ConnectionAnnouncement = *req.Settings.ConnectionAnnouncement } + if req.Settings.AllowPassword != nil { + namespace.Settings.AllowPassword = *req.Settings.AllowPassword + } + + if req.Settings.AllowPublicKey != nil { + namespace.Settings.AllowPublicKey = *req.Settings.AllowPublicKey + } + + if req.Settings.AllowRoot != nil { + namespace.Settings.AllowRoot = *req.Settings.AllowRoot + } + + if req.Settings.AllowEmptyPasswords != nil { + namespace.Settings.AllowEmptyPasswords = *req.Settings.AllowEmptyPasswords + } + + if req.Settings.AllowTTY != nil { + namespace.Settings.AllowTTY = *req.Settings.AllowTTY + } + + if req.Settings.AllowTCPForwarding != nil { + namespace.Settings.AllowTCPForwarding = *req.Settings.AllowTCPForwarding + } + + if req.Settings.AllowWebEndpoints != nil { + namespace.Settings.AllowWebEndpoints = *req.Settings.AllowWebEndpoints + } + + if req.Settings.AllowSFTP != nil { + namespace.Settings.AllowSFTP = *req.Settings.AllowSFTP + } + + if req.Settings.AllowAgentForwarding != nil { + namespace.Settings.AllowAgentForwarding = *req.Settings.AllowAgentForwarding + } + if err := s.store.NamespaceUpdate(ctx, namespace); err != nil { return nil, err } diff --git a/api/services/namespace_test.go b/api/services/namespace_test.go index 64fde9b1932..8f040d0b15d 100644 --- a/api/services/namespace_test.go +++ b/api/services/namespace_test.go @@ -605,6 +605,15 @@ func TestCreateNamespace(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, MaxDevices: -1, }, @@ -681,6 +690,15 @@ func TestCreateNamespace(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, MaxDevices: -1, }, @@ -704,6 +722,15 @@ func TestCreateNamespace(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, MaxDevices: -1, }, @@ -778,6 +805,15 @@ func TestCreateNamespace(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, MaxDevices: -1, }, @@ -801,6 +837,15 @@ func TestCreateNamespace(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, MaxDevices: -1, }, @@ -872,6 +917,15 @@ func TestCreateNamespace(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: "", + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, MaxDevices: -1, }, @@ -895,6 +949,15 @@ func TestCreateNamespace(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: "", + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, MaxDevices: -1, }, @@ -966,6 +1029,15 @@ func TestCreateNamespace(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: "", + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, MaxDevices: 3, }, @@ -989,6 +1061,15 @@ func TestCreateNamespace(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: "", + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, MaxDevices: 3, }, @@ -1026,7 +1107,13 @@ func TestEditNamespace(t *testing.T) { requiredMocks func() tenantID string namespaceName string - expected Expected + settings struct { + SessionRecord *bool + ConnectionAnnouncement *string + AllowPassword *bool + AllowPublicKey *bool + } + expected Expected }{ { description: "fails when namespace does not exist", @@ -1111,10 +1198,112 @@ func TestEditNamespace(t *testing.T) { nil, }, }, + { + description: "succeeds changing AllowPassword", + tenantID: "xxxxx", + settings: struct { + SessionRecord *bool + ConnectionAnnouncement *string + AllowPassword *bool + AllowPublicKey *bool + }{ + AllowPassword: func(b bool) *bool { return &b }(true), + }, + requiredMocks: func() { + namespace := &models.Namespace{ + TenantID: "xxxxx", + Name: "oldname", + Settings: &models.NamespaceSettings{AllowPassword: false}, + } + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "xxxxx"). + Return(namespace, nil). + Once() + + expectedNamespace := *namespace + expectedNamespace.Settings.AllowPassword = true + storeMock. + On("NamespaceUpdate", ctx, &expectedNamespace). + Return(nil). + Once() + + finalNamespace := &models.Namespace{ + TenantID: "xxxxx", + Name: "oldname", + Settings: &models.NamespaceSettings{AllowPassword: true}, + } + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "xxxxx"). + Return(finalNamespace, nil). + Once() + }, + expected: Expected{ + &models.Namespace{ + TenantID: "xxxxx", + Name: "oldname", + Settings: &models.NamespaceSettings{AllowPassword: true}, + }, + nil, + }, + }, + { + description: "succeeds changing AllowPublicKey", + tenantID: "xxxxx", + settings: struct { + SessionRecord *bool + ConnectionAnnouncement *string + AllowPassword *bool + AllowPublicKey *bool + }{ + AllowPublicKey: func(b bool) *bool { return &b }(true), + }, + requiredMocks: func() { + namespace := &models.Namespace{ + TenantID: "xxxxx", + Name: "oldname", + Settings: &models.NamespaceSettings{AllowPublicKey: false}, + } + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "xxxxx"). + Return(namespace, nil). + Once() + + expectedNamespace := *namespace + expectedNamespace.Settings.AllowPublicKey = true + storeMock. + On("NamespaceUpdate", ctx, &expectedNamespace). + Return(nil). + Once() + + finalNamespace := &models.Namespace{ + TenantID: "xxxxx", + Name: "oldname", + Settings: &models.NamespaceSettings{AllowPublicKey: true}, + } + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "xxxxx"). + Return(finalNamespace, nil). + Once() + }, + expected: Expected{ + &models.Namespace{ + TenantID: "xxxxx", + Name: "oldname", + Settings: &models.NamespaceSettings{AllowPublicKey: true}, + }, + nil, + }, + }, { description: "succeeds", namespaceName: "newname", tenantID: "xxxxx", + settings: struct { + SessionRecord *bool + ConnectionAnnouncement *string + AllowPassword *bool + AllowPublicKey *bool + }{}, requiredMocks: func() { namespace := &models.Namespace{ TenantID: "xxxxx", @@ -1163,6 +1352,11 @@ func TestEditNamespace(t *testing.T) { TenantParam: requests.TenantParam{Tenant: tc.tenantID}, Name: tc.namespaceName, } + req.Settings.SessionRecord = tc.settings.SessionRecord + req.Settings.ConnectionAnnouncement = tc.settings.ConnectionAnnouncement + req.Settings.AllowPassword = tc.settings.AllowPassword + req.Settings.AllowPublicKey = tc.settings.AllowPublicKey + namespace, err := service.EditNamespace(ctx, req) assert.Equal(t, tc.expected, Expected{namespace, err}) diff --git a/api/services/setup.go b/api/services/setup.go index 2e5b0553905..fb91dbc70e7 100644 --- a/api/services/setup.go +++ b/api/services/setup.go @@ -88,6 +88,15 @@ func (s *service) Setup(ctx context.Context, req requests.Setup) error { Settings: &models.NamespaceSettings{ SessionRecord: false, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, } diff --git a/api/services/setup_test.go b/api/services/setup_test.go index 7eea9a43660..ffeb898877d 100644 --- a/api/services/setup_test.go +++ b/api/services/setup_test.go @@ -197,6 +197,15 @@ func TestSetup(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: false, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, CreatedAt: now, } @@ -287,6 +296,15 @@ func TestSetup(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: false, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, CreatedAt: now, } @@ -354,6 +372,15 @@ func TestSetup(t *testing.T) { Settings: &models.NamespaceSettings{ SessionRecord: false, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, CreatedAt: now, } diff --git a/api/store/migrate/deep_validate_namespaces.go b/api/store/migrate/deep_validate_namespaces.go index a8a1df269e8..81b397fc77b 100644 --- a/api/store/migrate/deep_validate_namespaces.go +++ b/api/store/migrate/deep_validate_namespaces.go @@ -95,7 +95,7 @@ func (m *Migrator) compareNamespaceBatch(ctx context.Context, r *ValidationRepor r.CheckField(t, id, "DevicesPendingCount", exp.DevicesPendingCount, act.DevicesPendingCount) r.CheckField(t, id, "DevicesRejectedCount", exp.DevicesRejectedCount, act.DevicesRejectedCount) r.CheckField(t, id, "DevicesRemovedCount", exp.DevicesRemovedCount, act.DevicesRemovedCount) - r.CheckField(t, id, "Settings.MaxDevices", exp.Settings.MaxDevices, act.Settings.MaxDevices) + r.CheckField(t, id, "MaxDevices", exp.MaxDevices, act.MaxDevices) r.CheckField(t, id, "Settings.SessionRecord", exp.Settings.SessionRecord, act.Settings.SessionRecord) r.CheckField(t, id, "Settings.ConnectionAnnouncement", exp.Settings.ConnectionAnnouncement, act.Settings.ConnectionAnnouncement) } diff --git a/api/store/migrate/namespaces.go b/api/store/migrate/namespaces.go index 97f11ace0c1..f242a38a1db 100644 --- a/api/store/migrate/namespaces.go +++ b/api/store/migrate/namespaces.go @@ -49,12 +49,13 @@ func convertNamespace(doc mongoNamespace) *entity.Namespace { Type: nsType, Name: doc.Name, OwnerID: ObjectIDToUUID(doc.Owner), + MaxDevices: doc.MaxDevices, DevicesAcceptedCount: doc.DevicesAcceptedCount, DevicesPendingCount: doc.DevicesPendingCount, DevicesRejectedCount: doc.DevicesRejectedCount, DevicesRemovedCount: doc.DevicesRemovedCount, - Settings: entity.NamespaceSettings{ - MaxDevices: doc.MaxDevices, + Settings: &entity.NamespaceSettings{ + NamespaceID: doc.TenantID, }, } @@ -63,6 +64,16 @@ func convertNamespace(doc mongoNamespace) *entity.Namespace { e.Settings.ConnectionAnnouncement = doc.Settings.ConnectionAnnouncement } + e.Settings.AllowPassword = true + e.Settings.AllowPublicKey = true + e.Settings.AllowRoot = true + e.Settings.AllowEmptyPasswords = true + e.Settings.AllowTTY = true + e.Settings.AllowTCPForwarding = true + e.Settings.AllowWebEndpoints = true + e.Settings.AllowSFTP = true + e.Settings.AllowAgentForwarding = true + return e } diff --git a/api/store/migrate/namespaces_test.go b/api/store/migrate/namespaces_test.go index 406dd08e5ee..0081105d732 100644 --- a/api/store/migrate/namespaces_test.go +++ b/api/store/migrate/namespaces_test.go @@ -40,7 +40,7 @@ func TestConvertNamespace(t *testing.T) { assert.Equal(t, int64(2), result.DevicesPendingCount) assert.Equal(t, int64(1), result.DevicesRejectedCount) assert.Equal(t, int64(3), result.DevicesRemovedCount) - assert.Equal(t, 10, result.Settings.MaxDevices) + assert.Equal(t, 10, result.MaxDevices) assert.True(t, result.Settings.SessionRecord) assert.Equal(t, "Welcome!", result.Settings.ConnectionAnnouncement) }) @@ -65,7 +65,7 @@ func TestConvertNamespace(t *testing.T) { result := convertNamespace(doc) - assert.Equal(t, 3, result.Settings.MaxDevices) + assert.Equal(t, 3, result.MaxDevices) assert.False(t, result.Settings.SessionRecord) assert.Empty(t, result.Settings.ConnectionAnnouncement) }) diff --git a/api/store/mongo/device.go b/api/store/mongo/device.go index 50857f35b58..38bf154e1a6 100644 --- a/api/store/mongo/device.go +++ b/api/store/mongo/device.go @@ -273,6 +273,9 @@ func (s *Store) DeviceConflicts(ctx context.Context, target *models.DeviceConfli } func (s *Store) DeviceUpdate(ctx context.Context, device *models.Device) error { + // Keep track of whether SSH was explicitly set by the caller + hasSSH := device.SSH != nil + bsonBytes, err := bson.Marshal(device) if err != nil { return FromMongoError(err) @@ -283,6 +286,12 @@ func (s *Store) DeviceUpdate(ctx context.Context, device *models.Device) error { return FromMongoError(err) } + // Only include SSH in the update if it was explicitly set (not nil) + // This preserves the existing DB value when SSH is nil + if !hasSSH { + delete(doc, "ssh") + } + // Convert string TagIDs to MongoDB ObjectIDs for referential integrity delete(doc, "tags") if tagIDs, ok := doc["tag_ids"].(bson.A); ok && len(tagIDs) > 0 { diff --git a/api/store/mongo/migrations/main.go b/api/store/mongo/migrations/main.go index 34d8dc86819..da597cdaa4b 100644 --- a/api/store/mongo/migrations/main.go +++ b/api/store/mongo/migrations/main.go @@ -130,6 +130,7 @@ func GenerateMigrations() []migrate.Migration { migration118, migration119, migration120, + migration121, } } diff --git a/api/store/mongo/migrations/migration_121.go b/api/store/mongo/migrations/migration_121.go new file mode 100644 index 00000000000..6a188ac035b --- /dev/null +++ b/api/store/mongo/migrations/migration_121.go @@ -0,0 +1,91 @@ +package migrations + +import ( + "context" + + log "github.com/sirupsen/logrus" + migrate "github.com/xakep666/mongo-migrate" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +var migration121 = migrate.Migration{ + Version: 121, + Description: "Add namespace and device SSH settings", + Up: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{"component": "migration", "version": 121, "action": "Up"}).Info("Applying migration") + + if _, err := db.Collection("namespaces").UpdateMany(ctx, bson.M{}, bson.M{ + "$set": bson.M{ + "settings.allow_password": true, + "settings.allow_public_key": true, + "settings.allow_root": true, + "settings.allow_empty_passwords": true, + "settings.allow_tty": true, + "settings.allow_tcp_forwarding": true, + "settings.allow_web_endpoints": true, + "settings.allow_sftp": true, + "settings.allow_agent_forwarding": true, + }, + }); err != nil { + log.WithError(err).Error("Failed to add allow_* settings to namespace settings") + + return err + } + + if _, err := db.Collection("devices").UpdateMany(ctx, bson.M{}, bson.M{ + "$set": bson.M{ + "ssh": bson.M{ + "allow_password": true, + "allow_public_key": true, + "allow_root": true, + "allow_empty_passwords": true, + "allow_tty": true, + "allow_tcp_forwarding": true, + "allow_web_endpoints": true, + "allow_sftp": true, + "allow_agent_forwarding": true, + }, + }, + }); err != nil { + log.WithError(err).Error("Failed to add ssh settings to devices") + + return err + } + + return nil + }), + Down: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{"component": "migration", "version": 121, "action": "Down"}).Info("Reverting migration") + + if _, err := db.Collection("namespaces").UpdateMany(ctx, bson.M{}, bson.M{ + "$unset": bson.M{ + "settings.allow_password": "", + "settings.allow_public_key": "", + "settings.allow_root": "", + "settings.allow_empty_passwords": "", + "settings.allow_tty": "", + "settings.allow_tcp_forwarding": "", + "settings.allow_web_endpoints": "", + "settings.allow_sftp": "", + "settings.allow_agent_forwarding": "", + }, + }); err != nil { + log.WithError(err).Error("Failed to remove allow_* settings from namespace settings") + + return err + } + + if _, err := db.Collection("devices").UpdateMany(ctx, bson.M{}, bson.M{ + "$unset": bson.M{ + "ssh": "", + }, + }); err != nil { + log.WithError(err).Error("Failed to remove ssh settings from devices") + + return err + } + + return nil + }), +} diff --git a/api/store/pg/device.go b/api/store/pg/device.go index 26a7ebfd033..cc80e1fd8cc 100644 --- a/api/store/pg/device.go +++ b/api/store/pg/device.go @@ -22,6 +22,13 @@ func (pg *Pg) DeviceCreate(ctx context.Context, device *models.Device) (string, return "", fromSQLError(err) } + if device.SSH != nil { + settings := entity.DeviceSettingsFromModel(device.SSH, device.UID) + if _, err := db.NewInsert().Model(&settings).Exec(ctx); err != nil { + return "", fromSQLError(err) + } + } + return e.ID, nil } @@ -75,6 +82,7 @@ func (pg *Pg) DeviceList(ctx context.Context, acceptable store.DeviceAcceptable, Model(&entities). Column("device.*"). Relation("Namespace"). + Relation("Settings"). Relation("Tags"). ColumnExpr(onlineExpr, onlineThreshold). ColumnExpr(deviceExprAcceptable(acceptable)) @@ -117,6 +125,7 @@ func (pg *Pg) DeviceResolve(ctx context.Context, resolver store.DeviceResolver, Where("? = ?", bun.Ident("device."+column), val). Column("device.*"). Relation("Namespace"). + Relation("Settings"). Relation("Tags"). ColumnExpr(onlineExpr, onlineThreshold) @@ -149,6 +158,16 @@ func (pg *Pg) DeviceUpdate(ctx context.Context, device *models.Device) error { return store.ErrNoDocuments } + if device.SSH != nil { + settings := entity.DeviceSettingsFromModel(device.SSH, device.UID) + settings.UpdatedAt = clock.Now() + + _, err = db.NewInsert().On("conflict (device_id) do update").Model(&settings).Exec(ctx) + if err != nil { + return fromSQLError(err) + } + } + return nil } @@ -218,6 +237,13 @@ func (pg *Pg) DeviceDeleteMany(ctx context.Context, uids []string) (int64, error func (pg *Pg) deviceDeleteManyFn(ctx context.Context, uids []string) func(tx bun.Tx) (int64, error) { return func(tx bun.Tx) (int64, error) { + if _, err := tx.NewDelete(). + Model((*entity.DeviceSettings)(nil)). + Where("device_id IN (?)", bun.List(uids)). + Exec(ctx); err != nil { + return 0, fromSQLError(err) + } + r, err := tx.NewDelete().Model((*entity.Device)(nil)).Where("id IN (?)", bun.List(uids)).Exec(ctx) if err != nil { return 0, fromSQLError(err) diff --git a/api/store/pg/entity/device.go b/api/store/pg/entity/device.go index b90a1e94da6..a2220087305 100644 --- a/api/store/pg/entity/device.go +++ b/api/store/pg/entity/device.go @@ -32,8 +32,9 @@ type Device struct { Longitude float64 `bun:"longitude,type:numeric"` Latitude float64 `bun:"latitude,type:numeric"` - Namespace *Namespace `bun:"rel:belongs-to,join:namespace_id=id"` - Tags []*Tag `bun:"m2m:device_tags,join:Device=Tag"` + Namespace *Namespace `bun:"rel:belongs-to,join:namespace_id=id"` + Tags []*Tag `bun:"m2m:device_tags,join:Device=Tag"` + Settings *DeviceSettings `bun:"rel:has-one,join:id=device_id"` } func DeviceFromModel(model *models.Device) *Device { @@ -149,5 +150,11 @@ func DeviceToModel(entity *Device) *models.Device { } } + if entity.Settings != nil { + device.SSH = DeviceSettingsToModel(entity.Settings) + } else { + device.SSH = models.DefaultSSHSettings() + } + return device } diff --git a/api/store/pg/entity/device_settings.go b/api/store/pg/entity/device_settings.go new file mode 100644 index 00000000000..8602507ae6e --- /dev/null +++ b/api/store/pg/entity/device_settings.go @@ -0,0 +1,77 @@ +package entity + +import ( + "time" + + "github.com/shellhub-io/shellhub/pkg/models" + "github.com/shellhub-io/shellhub/pkg/uuid" + "github.com/uptrace/bun" +) + +type DeviceSettings struct { + bun.BaseModel `bun:"table:device_settings"` + + ID string `bun:"id,pk,type:uuid,nullzero,default:gen_random_uuid()"` + DeviceID string `bun:"device_id,type:varchar,unique"` + AllowPassword bool `bun:"allow_password"` + AllowPublicKey bool `bun:"allow_public_key"` + AllowRoot bool `bun:"allow_root"` + AllowEmptyPasswords bool `bun:"allow_empty_passwords"` + AllowTTY bool `bun:"allow_tty"` + AllowTCPForwarding bool `bun:"allow_tcp_forwarding"` + AllowWebEndpoints bool `bun:"allow_web_endpoints"` + AllowSFTP bool `bun:"allow_sftp"` + AllowAgentForwarding bool `bun:"allow_agent_forwarding"` + CreatedAt time.Time `bun:"created_at"` + UpdatedAt time.Time `bun:"updated_at"` +} + +func DeviceSettingsFromModel(ssh *models.SSHSettings, deviceID string) DeviceSettings { + if ssh == nil { + return DeviceSettings{ + ID: uuid.Generate(), + DeviceID: deviceID, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, + } + } + + return DeviceSettings{ + ID: uuid.Generate(), + DeviceID: deviceID, + AllowPassword: ssh.AllowPassword, + AllowPublicKey: ssh.AllowPublicKey, + AllowRoot: ssh.AllowRoot, + AllowEmptyPasswords: ssh.AllowEmptyPasswords, + AllowTTY: ssh.AllowTTY, + AllowTCPForwarding: ssh.AllowTCPForwarding, + AllowWebEndpoints: ssh.AllowWebEndpoints, + AllowSFTP: ssh.AllowSFTP, + AllowAgentForwarding: ssh.AllowAgentForwarding, + } +} + +func DeviceSettingsToModel(settings *DeviceSettings) *models.SSHSettings { + if settings == nil { + return nil + } + + return &models.SSHSettings{ + AllowPassword: settings.AllowPassword, + AllowPublicKey: settings.AllowPublicKey, + AllowRoot: settings.AllowRoot, + AllowEmptyPasswords: settings.AllowEmptyPasswords, + AllowTTY: settings.AllowTTY, + AllowTCPForwarding: settings.AllowTCPForwarding, + AllowWebEndpoints: settings.AllowWebEndpoints, + AllowSFTP: settings.AllowSFTP, + AllowAgentForwarding: settings.AllowAgentForwarding, + } +} diff --git a/api/store/pg/entity/device_settings_test.go b/api/store/pg/entity/device_settings_test.go new file mode 100644 index 00000000000..1246ea77771 --- /dev/null +++ b/api/store/pg/entity/device_settings_test.go @@ -0,0 +1,35 @@ +package entity + +import "testing" + +func TestDeviceSettingsFromModelNilDefaultsToEnabled(t *testing.T) { + settings := DeviceSettingsFromModel(nil, "device-id") + + if !settings.AllowPassword { + t.Fatal("expected AllowPassword to default to true") + } + if !settings.AllowPublicKey { + t.Fatal("expected AllowPublicKey to default to true") + } + if !settings.AllowRoot { + t.Fatal("expected AllowRoot to default to true") + } + if !settings.AllowEmptyPasswords { + t.Fatal("expected AllowEmptyPasswords to default to true") + } + if !settings.AllowTTY { + t.Fatal("expected AllowTTY to default to true") + } + if !settings.AllowTCPForwarding { + t.Fatal("expected AllowTCPForwarding to default to true") + } + if !settings.AllowWebEndpoints { + t.Fatal("expected AllowWebEndpoints to default to true") + } + if !settings.AllowSFTP { + t.Fatal("expected AllowSFTP to default to true") + } + if !settings.AllowAgentForwarding { + t.Fatal("expected AllowAgentForwarding to default to true") + } +} diff --git a/api/store/pg/entity/device_test.go b/api/store/pg/entity/device_test.go index ae1a15304fb..64d90f7b7be 100644 --- a/api/store/pg/entity/device_test.go +++ b/api/store/pg/entity/device_test.go @@ -281,6 +281,25 @@ func TestDeviceToModel(t *testing.T) { assert.Nil(t, result.TagIDs) }, }, + { + name: "nil settings default to allow all", + entity: &Device{ + ID: "device-uid-7", + Status: "accepted", + }, + check: func(t *testing.T, result *models.Device) { + require.NotNil(t, result.SSH) + assert.True(t, result.SSH.AllowPassword) + assert.True(t, result.SSH.AllowPublicKey) + assert.True(t, result.SSH.AllowRoot) + assert.True(t, result.SSH.AllowEmptyPasswords) + assert.True(t, result.SSH.AllowTTY) + assert.True(t, result.SSH.AllowTCPForwarding) + assert.True(t, result.SSH.AllowWebEndpoints) + assert.True(t, result.SSH.AllowSFTP) + assert.True(t, result.SSH.AllowAgentForwarding) + }, + }, } for _, tt := range tests { diff --git a/api/store/pg/entity/entity.go b/api/store/pg/entity/entity.go index ce625a48ff8..7c2a2b6cd32 100644 --- a/api/store/pg/entity/entity.go +++ b/api/store/pg/entity/entity.go @@ -8,8 +8,10 @@ func Entities() []any { (*APIKey)(nil), (*Device)(nil), + (*DeviceSettings)(nil), (*Membership)(nil), (*Namespace)(nil), + (*NamespaceSettings)(nil), (*PrivateKey)(nil), (*PublicKey)(nil), (*Session)(nil), diff --git a/api/store/pg/entity/namespace.go b/api/store/pg/entity/namespace.go index 64ead23d2b1..78f95f10efd 100644 --- a/api/store/pg/entity/namespace.go +++ b/api/store/pg/entity/namespace.go @@ -4,30 +4,48 @@ import ( "time" "github.com/shellhub-io/shellhub/pkg/models" + "github.com/shellhub-io/shellhub/pkg/uuid" "github.com/uptrace/bun" ) type Namespace struct { bun.BaseModel `bun:"table:namespaces"` - ID string `bun:"id,pk,type:uuid"` - CreatedAt time.Time `bun:"created_at"` - UpdatedAt time.Time `bun:"updated_at"` - Type string `bun:"scope"` - Name string `bun:"name"` - OwnerID string `bun:"owner_id"` // TODO: Remove this column in the future, owner should be determined by membership role - Memberships []Membership `json:"members" bun:"rel:has-many,join:id=namespace_id"` - Settings NamespaceSettings `bun:"embed:"` - DevicesAcceptedCount int64 `bun:"devices_accepted_count"` - DevicesPendingCount int64 `bun:"devices_pending_count"` - DevicesRejectedCount int64 `bun:"devices_rejected_count"` - DevicesRemovedCount int64 `bun:"devices_removed_count"` + ID string `bun:"id,pk,type:uuid"` + CreatedAt time.Time `bun:"created_at"` + UpdatedAt time.Time `bun:"updated_at"` + Type string `bun:"scope"` + Name string `bun:"name"` + OwnerID string `bun:"owner_id"` + SessionRecord bool `bun:"record_sessions"` + ConnectionAnnouncement string `bun:"connection_announcement,type:text"` + Memberships []Membership `json:"members" bun:"rel:has-many,join:id=namespace_id"` + Settings *NamespaceSettings `bun:"rel:has-one,join:id=namespace_id"` + MaxDevices int `bun:"max_devices"` + DevicesAcceptedCount int64 `bun:"devices_accepted_count"` + DevicesPendingCount int64 `bun:"devices_pending_count"` + DevicesRejectedCount int64 `bun:"devices_rejected_count"` + DevicesRemovedCount int64 `bun:"devices_removed_count"` } type NamespaceSettings struct { - MaxDevices int `bun:"max_devices"` - SessionRecord bool `bun:"record_sessions"` - ConnectionAnnouncement string `bun:"connection_announcement,type:text"` + bun.BaseModel `bun:"table:namespace_settings"` + + ID string `bun:"id,pk,type:uuid,nullzero,default:gen_random_uuid()"` + NamespaceID string `bun:"namespace_id,type:uuid,unique"` + SessionRecord bool `bun:"record_sessions"` + ConnectionAnnouncement string `bun:"connection_announcement,type:text"` + AllowPassword bool `bun:"allow_password"` + AllowPublicKey bool `bun:"allow_public_key"` + AllowRoot bool `bun:"allow_root"` + AllowEmptyPasswords bool `bun:"allow_empty_passwords"` + AllowTTY bool `bun:"allow_tty"` + AllowTCPForwarding bool `bun:"allow_tcp_forwarding"` + AllowWebEndpoints bool `bun:"allow_web_endpoints"` + AllowSFTP bool `bun:"allow_sftp"` + AllowAgentForwarding bool `bun:"allow_agent_forwarding"` + CreatedAt time.Time `bun:"created_at"` + UpdatedAt time.Time `bun:"updated_at"` } func NamespaceFromModel(model *models.Namespace) *Namespace { @@ -38,23 +56,38 @@ func NamespaceFromModel(model *models.Namespace) *Namespace { } namespace := &Namespace{ - ID: model.TenantID, - CreatedAt: model.CreatedAt, - Type: namespaceType, - Name: model.Name, - OwnerID: model.Owner, - DevicesAcceptedCount: model.DevicesAcceptedCount, - DevicesPendingCount: model.DevicesPendingCount, - DevicesRejectedCount: model.DevicesRejectedCount, - DevicesRemovedCount: model.DevicesRemovedCount, - Settings: NamespaceSettings{ - MaxDevices: model.MaxDevices, - }, + ID: model.TenantID, + CreatedAt: model.CreatedAt, + Type: namespaceType, + Name: model.Name, + OwnerID: model.Owner, + SessionRecord: false, + ConnectionAnnouncement: "", + MaxDevices: model.MaxDevices, + DevicesAcceptedCount: model.DevicesAcceptedCount, + DevicesPendingCount: model.DevicesPendingCount, + DevicesRejectedCount: model.DevicesRejectedCount, + DevicesRemovedCount: model.DevicesRemovedCount, } if model.Settings != nil { - namespace.Settings.SessionRecord = model.Settings.SessionRecord - namespace.Settings.ConnectionAnnouncement = model.Settings.ConnectionAnnouncement + namespace.SessionRecord = model.Settings.SessionRecord + namespace.ConnectionAnnouncement = model.Settings.ConnectionAnnouncement + namespace.Settings = &NamespaceSettings{ + ID: uuid.Generate(), + NamespaceID: model.TenantID, + SessionRecord: model.Settings.SessionRecord, + ConnectionAnnouncement: model.Settings.ConnectionAnnouncement, + AllowPassword: model.Settings.AllowPassword, + AllowPublicKey: model.Settings.AllowPublicKey, + AllowRoot: model.Settings.AllowRoot, + AllowEmptyPasswords: model.Settings.AllowEmptyPasswords, + AllowTTY: model.Settings.AllowTTY, + AllowTCPForwarding: model.Settings.AllowTCPForwarding, + AllowWebEndpoints: model.Settings.AllowWebEndpoints, + AllowSFTP: model.Settings.AllowSFTP, + AllowAgentForwarding: model.Settings.AllowAgentForwarding, + } } namespace.Memberships = make([]Membership, len(model.Members)) @@ -77,15 +110,27 @@ func NamespaceToModel(entity *Namespace) *models.Namespace { Owner: entity.OwnerID, CreatedAt: entity.CreatedAt, Type: models.Type(entity.Type), - MaxDevices: entity.Settings.MaxDevices, + MaxDevices: entity.MaxDevices, DevicesAcceptedCount: entity.DevicesAcceptedCount, DevicesPendingCount: entity.DevicesPendingCount, DevicesRejectedCount: entity.DevicesRejectedCount, DevicesRemovedCount: entity.DevicesRemovedCount, - Settings: &models.NamespaceSettings{ + } + + if entity.Settings != nil { + namespace.Settings = &models.NamespaceSettings{ SessionRecord: entity.Settings.SessionRecord, ConnectionAnnouncement: entity.Settings.ConnectionAnnouncement, - }, + } + namespace.Settings.AllowPassword = entity.Settings.AllowPassword + namespace.Settings.AllowPublicKey = entity.Settings.AllowPublicKey + namespace.Settings.AllowRoot = entity.Settings.AllowRoot + namespace.Settings.AllowEmptyPasswords = entity.Settings.AllowEmptyPasswords + namespace.Settings.AllowTTY = entity.Settings.AllowTTY + namespace.Settings.AllowTCPForwarding = entity.Settings.AllowTCPForwarding + namespace.Settings.AllowWebEndpoints = entity.Settings.AllowWebEndpoints + namespace.Settings.AllowSFTP = entity.Settings.AllowSFTP + namespace.Settings.AllowAgentForwarding = entity.Settings.AllowAgentForwarding } namespace.Members = make([]models.Member, len(entity.Memberships)) diff --git a/api/store/pg/entity/namespace_test.go b/api/store/pg/entity/namespace_test.go index 00ee60d3161..3927991322b 100644 --- a/api/store/pg/entity/namespace_test.go +++ b/api/store/pg/entity/namespace_test.go @@ -49,12 +49,12 @@ func TestNamespaceFromModel(t *testing.T) { CreatedAt: now, }, expected: &Namespace{ - ID: "ns-id-1", - Name: "my-namespace", - OwnerID: "owner-id-1", - Type: "team", - Settings: NamespaceSettings{ - MaxDevices: 10, + ID: "ns-id-1", + Name: "my-namespace", + OwnerID: "owner-id-1", + Type: "team", + MaxDevices: 10, + Settings: &NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: "Welcome!", }, @@ -108,13 +108,12 @@ func TestNamespaceFromModel(t *testing.T) { Members: []models.Member{}, }, expected: &Namespace{ - ID: "ns-id-3", - Name: "no-settings", - OwnerID: "owner-id-3", - Type: "personal", - Settings: NamespaceSettings{ - MaxDevices: 15, - }, + ID: "ns-id-3", + Name: "no-settings", + OwnerID: "owner-id-3", + Type: "personal", + MaxDevices: 15, + Settings: nil, Memberships: []Membership{}, }, }, @@ -144,9 +143,11 @@ func TestNamespaceFromModel(t *testing.T) { assert.Equal(t, tt.expected.Name, result.Name) assert.Equal(t, tt.expected.OwnerID, result.OwnerID) assert.Equal(t, tt.expected.Type, result.Type) - assert.Equal(t, tt.expected.Settings.MaxDevices, result.Settings.MaxDevices) - assert.Equal(t, tt.expected.Settings.SessionRecord, result.Settings.SessionRecord) - assert.Equal(t, tt.expected.Settings.ConnectionAnnouncement, result.Settings.ConnectionAnnouncement) + assert.Equal(t, tt.expected.MaxDevices, result.MaxDevices) + if tt.expected.Settings != nil { + assert.Equal(t, tt.expected.Settings.SessionRecord, result.Settings.SessionRecord) + assert.Equal(t, tt.expected.Settings.ConnectionAnnouncement, result.Settings.ConnectionAnnouncement) + } assert.Equal(t, tt.expected.DevicesAcceptedCount, result.DevicesAcceptedCount) assert.Equal(t, tt.expected.DevicesPendingCount, result.DevicesPendingCount) assert.Equal(t, tt.expected.DevicesRejectedCount, result.DevicesRejectedCount) @@ -174,12 +175,12 @@ func TestNamespaceToModel(t *testing.T) { { name: "full fields", entity: &Namespace{ - ID: "ns-id-1", - Name: "my-namespace", - OwnerID: "owner-id-1", - Type: "team", - Settings: NamespaceSettings{ - MaxDevices: 10, + ID: "ns-id-1", + Name: "my-namespace", + OwnerID: "owner-id-1", + Type: "team", + MaxDevices: 10, + Settings: &NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: "Hello!", }, @@ -234,7 +235,7 @@ func TestNamespaceToModel(t *testing.T) { Name: "empty-ns", Owner: "owner-id-2", Type: models.TypePersonal, - Settings: &models.NamespaceSettings{}, + Settings: nil, Members: []models.Member{}, }, }, @@ -248,9 +249,13 @@ func TestNamespaceToModel(t *testing.T) { assert.Equal(t, tt.expected.Owner, result.Owner) assert.Equal(t, tt.expected.Type, result.Type) assert.Equal(t, tt.expected.MaxDevices, result.MaxDevices) - require.NotNil(t, result.Settings, "Settings should never be nil") - assert.Equal(t, tt.expected.Settings.SessionRecord, result.Settings.SessionRecord) - assert.Equal(t, tt.expected.Settings.ConnectionAnnouncement, result.Settings.ConnectionAnnouncement) + if tt.expected.Settings == nil { + assert.Nil(t, result.Settings) + } else { + require.NotNil(t, result.Settings, "Settings should not be nil") + assert.Equal(t, tt.expected.Settings.SessionRecord, result.Settings.SessionRecord) + assert.Equal(t, tt.expected.Settings.ConnectionAnnouncement, result.Settings.ConnectionAnnouncement) + } assert.Equal(t, tt.expected.DevicesAcceptedCount, result.DevicesAcceptedCount) assert.Equal(t, tt.expected.DevicesPendingCount, result.DevicesPendingCount) assert.Equal(t, tt.expected.DevicesRejectedCount, result.DevicesRejectedCount) diff --git a/api/store/pg/migrations/002_create_ssh_settings_tables.tx.down.sql b/api/store/pg/migrations/002_create_ssh_settings_tables.tx.down.sql new file mode 100644 index 00000000000..a14ffef2280 --- /dev/null +++ b/api/store/pg/migrations/002_create_ssh_settings_tables.tx.down.sql @@ -0,0 +1,17 @@ +-- Down migration: Restore columns and drop new tables + +-- Migrate data back from namespace_settings +UPDATE namespaces SET + record_sessions = ns.record_sessions, + connection_announcement = ns.connection_announcement +FROM namespace_settings ns +WHERE namespaces.id = ns.namespace_id; + +-- Drop tables +DROP TRIGGER IF EXISTS namespace_settings_updated_at ON namespace_settings; +DROP FUNCTION IF EXISTS update_namespace_settings_updated_at(); +DROP TABLE IF EXISTS namespace_settings; + +DROP TRIGGER IF EXISTS device_settings_updated_at ON device_settings; +DROP FUNCTION IF EXISTS update_device_settings_updated_at(); +DROP TABLE IF EXISTS device_settings; diff --git a/api/store/pg/migrations/002_create_ssh_settings_tables.tx.up.sql b/api/store/pg/migrations/002_create_ssh_settings_tables.tx.up.sql new file mode 100644 index 00000000000..86448edabb7 --- /dev/null +++ b/api/store/pg/migrations/002_create_ssh_settings_tables.tx.up.sql @@ -0,0 +1,82 @@ +-- Migration 002: Create device_settings table +CREATE TABLE IF NOT EXISTS device_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id character varying NOT NULL UNIQUE, + allow_password BOOLEAN DEFAULT TRUE, + allow_public_key BOOLEAN DEFAULT TRUE, + allow_root BOOLEAN DEFAULT TRUE, + allow_empty_passwords BOOLEAN DEFAULT TRUE, + allow_tty BOOLEAN DEFAULT TRUE, + allow_tcp_forwarding BOOLEAN DEFAULT TRUE, + allow_web_endpoints BOOLEAN DEFAULT TRUE, + allow_sftp BOOLEAN DEFAULT TRUE, + allow_agent_forwarding BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_device_settings_device_id ON device_settings(device_id); + +CREATE OR REPLACE FUNCTION update_device_settings_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER device_settings_updated_at + BEFORE UPDATE ON device_settings + FOR EACH ROW + EXECUTE FUNCTION update_device_settings_updated_at(); + +CREATE TABLE IF NOT EXISTS namespace_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + namespace_id UUID NOT NULL UNIQUE, + record_sessions BOOLEAN DEFAULT TRUE, + connection_announcement TEXT DEFAULT '', + allow_password BOOLEAN DEFAULT TRUE, + allow_public_key BOOLEAN DEFAULT TRUE, + allow_root BOOLEAN DEFAULT TRUE, + allow_empty_passwords BOOLEAN DEFAULT TRUE, + allow_tty BOOLEAN DEFAULT TRUE, + allow_tcp_forwarding BOOLEAN DEFAULT TRUE, + allow_web_endpoints BOOLEAN DEFAULT TRUE, + allow_sftp BOOLEAN DEFAULT TRUE, + allow_agent_forwarding BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_namespace_settings_namespace_id ON namespace_settings(namespace_id); + +CREATE OR REPLACE FUNCTION update_namespace_settings_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER namespace_settings_updated_at + BEFORE UPDATE ON namespace_settings + FOR EACH ROW + EXECUTE FUNCTION update_namespace_settings_updated_at(); + +-- Migrate data from namespaces table to namespace_settings table +INSERT INTO namespace_settings (namespace_id, record_sessions, connection_announcement, allow_password, allow_public_key, allow_root, allow_empty_passwords, allow_tty, allow_tcp_forwarding, allow_web_endpoints, allow_sftp, allow_agent_forwarding) +SELECT + id, + COALESCE(record_sessions, true), + COALESCE(connection_announcement, ''), + TRUE, + TRUE, + TRUE, + TRUE, + TRUE, + TRUE, + TRUE, + TRUE, + TRUE +FROM namespaces +ON CONFLICT (namespace_id) DO NOTHING; diff --git a/api/store/pg/namespace.go b/api/store/pg/namespace.go index e4589a00e64..3174177f4a3 100644 --- a/api/store/pg/namespace.go +++ b/api/store/pg/namespace.go @@ -24,6 +24,12 @@ func (pg *Pg) NamespaceCreate(ctx context.Context, namespace *models.Namespace) return "", fromSQLError(err) } + if nsEntity.Settings != nil { + if _, err := db.NewInsert().Model(nsEntity.Settings).Exec(ctx); err != nil { + return "", fromSQLError(err) + } + } + if len(nsEntity.Memberships) > 0 { if _, err := db.NewInsert().Model(&nsEntity.Memberships).Exec(ctx); err != nil { return "", fromSQLError(err) @@ -75,7 +81,7 @@ func (pg *Pg) NamespaceList(ctx context.Context, opts ...store.QueryOption) ([]m db := pg.GetConnection(ctx) entities := make([]entity.Namespace, 0) - query := db.NewSelect().Model(&entities) + query := db.NewSelect().Model(&entities).Relation("Settings") var err error query, err = applyOptions(ctx, query, opts...) @@ -105,7 +111,7 @@ func (pg *Pg) NamespaceResolve(ctx context.Context, resolver store.NamespaceReso } ns := new(entity.Namespace) - query := db.NewSelect().Model(ns).Relation("Memberships.User").Where("? = ?", bun.Ident(column), val) + query := db.NewSelect().Model(ns).Relation("Memberships.User").Relation("Settings").Where("? = ?", bun.Ident(column), val) if err := query.Scan(ctx); err != nil { return nil, fromSQLError(err) } @@ -120,6 +126,7 @@ func (pg *Pg) NamespaceGetPreferred(ctx context.Context, userID string) (*models if err := db.NewSelect(). Model(ns). Relation("Memberships.User"). + Relation("Settings"). Join("JOIN users"). JoinOn("namespace.id = users.preferred_namespace_id OR namespace.id IN (SELECT namespace_id FROM memberships WHERE user_id = users.id)"). Where("users.id = ?", userID). @@ -156,6 +163,14 @@ func (pg *Pg) NamespaceUpdate(ctx context.Context, namespace *models.Namespace) return store.ErrNoDocuments } + if n.Settings != nil { + n.Settings.UpdatedAt = clock.Now() + _, err = db.NewInsert().On("conflict (namespace_id) do update").Model(n.Settings).Exec(ctx) + if err != nil { + return fromSQLError(err) + } + } + return nil } @@ -318,9 +333,9 @@ func namespaceExprPreferredOrder() string { func NamespaceResolverToString(resolver store.NamespaceResolver) (string, error) { switch resolver { case store.NamespaceTenantIDResolver: - return "id", nil + return "namespace.id", nil case store.NamespaceNameResolver: - return "name", nil + return "namespace.name", nil default: return "", store.ErrResolverNotFound } diff --git a/cli/services/namespaces.go b/cli/services/namespaces.go index 0cb52871a45..7062c8543a8 100644 --- a/cli/services/namespaces.go +++ b/cli/services/namespaces.go @@ -49,6 +49,15 @@ func (s *service) NamespaceCreate(ctx context.Context, input *inputs.NamespaceCr Settings: &models.NamespaceSettings{ SessionRecord: true, ConnectionAnnouncement: models.DefaultAnnouncementMessage, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, }, CreatedAt: clock.Now(), Type: models.NewDefaultType(), diff --git a/cli/services/namespaces_test.go b/cli/services/namespaces_test.go index e83dbaf72c0..21225ebe0c9 100644 --- a/cli/services/namespaces_test.go +++ b/cli/services/namespaces_test.go @@ -18,6 +18,22 @@ import ( "github.com/stretchr/testify/assert" ) +func defaultNamespaceSettings(announcement string) *models.NamespaceSettings { + return &models.NamespaceSettings{ + SessionRecord: true, + ConnectionAnnouncement: announcement, + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, + } +} + func TestNamespaceCreate(t *testing.T) { type Expected struct { namespace *models.Namespace @@ -110,10 +126,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesUnlimited, CreatedAt: now, } @@ -153,10 +166,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesUnlimited, CreatedAt: now, } @@ -174,10 +184,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesUnlimited, CreatedAt: now, }, nil}, @@ -214,10 +221,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesLimited, CreatedAt: now, } @@ -235,10 +239,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesLimited, CreatedAt: now, }, nil}, @@ -275,10 +276,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesLimited, CreatedAt: now, } @@ -296,10 +294,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesLimited, CreatedAt: now, }, nil}, @@ -336,10 +331,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesUnlimited, CreatedAt: now, } @@ -357,10 +349,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesUnlimited, CreatedAt: now, }, nil}, @@ -397,10 +386,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesUnlimited, CreatedAt: now, } @@ -418,10 +404,7 @@ func TestNamespaceCreate(t *testing.T) { AddedAt: now, }, }, - Settings: &models.NamespaceSettings{ - SessionRecord: true, - ConnectionAnnouncement: models.DefaultAnnouncementMessage, - }, + Settings: defaultNamespaceSettings(models.DefaultAnnouncementMessage), MaxDevices: MaxNumberDevicesUnlimited, CreatedAt: now, }, nil}, diff --git a/openapi/spec/components/schemas/device.yaml b/openapi/spec/components/schemas/device.yaml index fd1094b1467..2cb42899680 100644 --- a/openapi/spec/components/schemas/device.yaml +++ b/openapi/spec/components/schemas/device.yaml @@ -60,6 +60,46 @@ properties: example: -52.322474 tags: $ref: deviceTags.yaml + settings: + description: Device's SSH configuration settings + type: object + properties: + allow_password: + description: Allow password authentication + type: boolean + example: true + allow_public_key: + description: Allow public key authentication + type: boolean + example: true + allow_root: + description: Allow root user login + type: boolean + example: true + allow_empty_passwords: + description: Allow empty passwords + type: boolean + example: false + allow_tty: + description: Allow TTY allocation + type: boolean + example: true + allow_tcp_forwarding: + description: Allow TCP port forwarding + type: boolean + example: true + allow_web_endpoints: + description: Allow web endpoints access via HTTP proxy + type: boolean + example: true + allow_sftp: + description: Allow SFTP subsystem + type: boolean + example: true + allow_agent_forwarding: + description: Allow SSH agent forwarding + type: boolean + example: false public_url: $ref: devicePublicURL.yaml acceptable: diff --git a/openapi/spec/components/schemas/deviceSSH.yaml b/openapi/spec/components/schemas/deviceSSH.yaml new file mode 100644 index 00000000000..fd9ffeeee0e --- /dev/null +++ b/openapi/spec/components/schemas/deviceSSH.yaml @@ -0,0 +1,39 @@ +type: object +description: Device's SSH configuration settings +properties: + allow_password: + description: Allow password authentication + type: boolean + example: true + allow_public_key: + description: Allow public key authentication + type: boolean + example: true + allow_root: + description: Allow root user login + type: boolean + example: true + allow_empty_passwords: + description: Allow empty passwords + type: boolean + example: false + allow_tty: + description: Allow TTY allocation + type: boolean + example: true + allow_tcp_forwarding: + description: Allow TCP port forwarding + type: boolean + example: true + allow_web_endpoints: + description: Allow web endpoints access via HTTP proxy + type: boolean + example: true + allow_sftp: + description: Allow SFTP subsystem + type: boolean + example: true + allow_agent_forwarding: + description: Allow SSH agent forwarding + type: boolean + example: false \ No newline at end of file diff --git a/openapi/spec/components/schemas/namespaceSettings.yaml b/openapi/spec/components/schemas/namespaceSettings.yaml index 515ba992e38..3a504472ab5 100644 --- a/openapi/spec/components/schemas/namespaceSettings.yaml +++ b/openapi/spec/components/schemas/namespaceSettings.yaml @@ -12,6 +12,44 @@ properties: maxLength: 4096 format: alphanumunicode example: my awesome connection announcement -required: + allow_password: + description: Allow password authentication at namespace level + type: boolean + example: true + allow_public_key: + description: Allow public key authentication at namespace level + type: boolean + example: true + allow_root: + description: Allow root user login at namespace level + type: boolean + example: true + allow_empty_passwords: + description: Allow empty passwords at namespace level + type: boolean + example: false + allow_tty: + description: Allow TTY allocation at namespace level + type: boolean + example: true + allow_tcp_forwarding: + description: Allow TCP port forwarding at namespace level + type: boolean + example: true + allow_web_endpoints: + description: Allow web endpoints access via HTTP proxy at namespace level + type: boolean + example: true + allow_sftp: + description: Allow SFTP subsystem at namespace level + type: boolean + example: true + allow_agent_forwarding: + description: Allow SSH agent forwarding at namespace level + type: boolean + example: false +required: - session_record - connection_announcement + - allow_password + - allow_public_key diff --git a/openapi/spec/paths/api@devices@{uid}.yaml b/openapi/spec/paths/api@devices@{uid}.yaml index 096569ff926..a5cc24263d2 100644 --- a/openapi/spec/paths/api@devices@{uid}.yaml +++ b/openapi/spec/paths/api@devices@{uid}.yaml @@ -62,6 +62,8 @@ put: $ref: ../components/schemas/deviceName.yaml public_url: $ref: ../components/schemas/devicePublicURL.yaml + settings: + $ref: ../components/schemas/deviceSSH.yaml required: - name responses: diff --git a/pkg/api/requests/device.go b/pkg/api/requests/device.go index 4d328cba559..b43d86e2767 100644 --- a/pkg/api/requests/device.go +++ b/pkg/api/requests/device.go @@ -17,6 +17,17 @@ type DeviceUpdate struct { TenantID string `header:"X-Tenant-ID"` UID string `param:"uid" validate:"required"` Name string `json:"name" validate:"device_name,omitempty"` + SSH *struct { + AllowPassword *bool `json:"allow_password" validate:"omitempty"` + AllowPublicKey *bool `json:"allow_public_key" validate:"omitempty"` + AllowRoot *bool `json:"allow_root" validate:"omitempty"` + AllowEmptyPasswords *bool `json:"allow_empty_passwords" validate:"omitempty"` + AllowTTY *bool `json:"allow_tty" validate:"omitempty"` + AllowTCPForwarding *bool `json:"allow_tcp_forwarding" validate:"omitempty"` + AllowWebEndpoints *bool `json:"allow_web_endpoints" validate:"omitempty"` + AllowSFTP *bool `json:"allow_sftp" validate:"omitempty"` + AllowAgentForwarding *bool `json:"allow_agent_forwarding" validate:"omitempty"` + } `json:"settings"` } // DeviceParam is a structure to represent and validate a device UID as path param. diff --git a/pkg/api/requests/namespace.go b/pkg/api/requests/namespace.go index 6e161b88d30..6cf555f3a5a 100644 --- a/pkg/api/requests/namespace.go +++ b/pkg/api/requests/namespace.go @@ -52,6 +52,15 @@ type NamespaceEdit struct { Settings struct { SessionRecord *bool `json:"session_record" validate:"omitempty"` ConnectionAnnouncement *string `json:"connection_announcement" validate:"omitempty,min=0,max=4096"` + AllowPassword *bool `json:"allow_password" validate:"omitempty"` + AllowPublicKey *bool `json:"allow_public_key" validate:"omitempty"` + AllowRoot *bool `json:"allow_root" validate:"omitempty"` + AllowEmptyPasswords *bool `json:"allow_empty_passwords" validate:"omitempty"` + AllowTTY *bool `json:"allow_tty" validate:"omitempty"` + AllowTCPForwarding *bool `json:"allow_tcp_forwarding" validate:"omitempty"` + AllowWebEndpoints *bool `json:"allow_web_endpoints" validate:"omitempty"` + AllowSFTP *bool `json:"allow_sftp" validate:"omitempty"` + AllowAgentForwarding *bool `json:"allow_agent_forwarding" validate:"omitempty"` } `json:"settings"` } diff --git a/pkg/models/device.go b/pkg/models/device.go index 063fcb42a5d..19d96730093 100644 --- a/pkg/models/device.go +++ b/pkg/models/device.go @@ -50,6 +50,7 @@ type Device struct { Acceptable bool `json:"acceptable" bson:"acceptable,omitempty"` Taggable `json:",inline" bson:",inline"` + SSH *SSHSettings `json:"settings" bson:"ssh,omitempty"` } type DeviceAuthRequest struct { @@ -99,6 +100,32 @@ type DeviceTag struct { Tag string `validate:"required,min=3,max=255,alphanum,ascii,excludes=/@&:"` } +type SSHSettings struct { + AllowPassword bool `json:"allow_password" bson:"allow_password,omitempty"` + AllowPublicKey bool `json:"allow_public_key" bson:"allow_public_key,omitempty"` + AllowRoot bool `json:"allow_root" bson:"allow_root,omitempty"` + AllowEmptyPasswords bool `json:"allow_empty_passwords" bson:"allow_empty_passwords,omitempty"` + AllowTTY bool `json:"allow_tty" bson:"allow_tty,omitempty"` + AllowTCPForwarding bool `json:"allow_tcp_forwarding" bson:"allow_tcp_forwarding,omitempty"` + AllowWebEndpoints bool `json:"allow_web_endpoints" bson:"allow_web_endpoints,omitempty"` + AllowSFTP bool `json:"allow_sftp" bson:"allow_sftp,omitempty"` + AllowAgentForwarding bool `json:"allow_agent_forwarding" bson:"allow_agent_forwarding,omitempty"` +} + +func DefaultSSHSettings() *SSHSettings { + return &SSHSettings{ + AllowPassword: true, + AllowPublicKey: true, + AllowRoot: true, + AllowEmptyPasswords: true, + AllowTTY: true, + AllowTCPForwarding: true, + AllowWebEndpoints: true, + AllowSFTP: true, + AllowAgentForwarding: true, + } +} + func NewDeviceTag(tag string) DeviceTag { return DeviceTag{ Tag: tag, diff --git a/pkg/models/device_test.go b/pkg/models/device_test.go new file mode 100644 index 00000000000..e9c0bcd1f75 --- /dev/null +++ b/pkg/models/device_test.go @@ -0,0 +1,35 @@ +package models + +import "testing" + +func TestDefaultSSHSettings(t *testing.T) { + settings := DefaultSSHSettings() + + if !settings.AllowPassword { + t.Fatal("expected AllowPassword to default to true") + } + if !settings.AllowPublicKey { + t.Fatal("expected AllowPublicKey to default to true") + } + if !settings.AllowRoot { + t.Fatal("expected AllowRoot to default to true") + } + if !settings.AllowEmptyPasswords { + t.Fatal("expected AllowEmptyPasswords to default to true") + } + if !settings.AllowTTY { + t.Fatal("expected AllowTTY to default to true") + } + if !settings.AllowTCPForwarding { + t.Fatal("expected AllowTCPForwarding to default to true") + } + if !settings.AllowWebEndpoints { + t.Fatal("expected AllowWebEndpoints to default to true") + } + if !settings.AllowSFTP { + t.Fatal("expected AllowSFTP to default to true") + } + if !settings.AllowAgentForwarding { + t.Fatal("expected AllowAgentForwarding to default to true") + } +} diff --git a/pkg/models/namespace.go b/pkg/models/namespace.go index 82095511ac9..293a885df04 100644 --- a/pkg/models/namespace.go +++ b/pkg/models/namespace.go @@ -51,6 +51,15 @@ func (n *Namespace) FindMember(id string) (*Member, bool) { type NamespaceSettings struct { SessionRecord bool `json:"session_record" bson:"session_record,omitempty"` ConnectionAnnouncement string `json:"connection_announcement" bson:"connection_announcement"` + AllowPassword bool `json:"allow_password" bson:"allow_password,omitempty"` + AllowPublicKey bool `json:"allow_public_key" bson:"allow_public_key,omitempty"` + AllowRoot bool `json:"allow_root" bson:"allow_root,omitempty"` + AllowEmptyPasswords bool `json:"allow_empty_passwords" bson:"allow_empty_passwords,omitempty"` + AllowTTY bool `json:"allow_tty" bson:"allow_tty,omitempty"` + AllowTCPForwarding bool `json:"allow_tcp_forwarding" bson:"allow_tcp_forwarding,omitempty"` + AllowWebEndpoints bool `json:"allow_web_endpoints" bson:"allow_web_endpoints,omitempty"` + AllowSFTP bool `json:"allow_sftp" bson:"allow_sftp,omitempty"` + AllowAgentForwarding bool `json:"allow_agent_forwarding" bson:"allow_agent_forwarding,omitempty"` } // default Announcement Message for the shellhub namespace diff --git a/ssh/http/handlers.go b/ssh/http/handlers.go index bc5615c01a6..2b4c6ff5950 100644 --- a/ssh/http/handlers.go +++ b/ssh/http/handlers.go @@ -103,6 +103,38 @@ func (h *Handlers) HandleHTTPProxy(c echo.Context) error { return c.JSON(http.StatusForbidden, NewMessageFromError(ErrWebEndpointForbidden)) } + // Check if device allows web endpoints + device, err := h.Client.GetDevice(c.Request().Context(), endpoint.DeviceUID) + if err != nil { + log.WithError(err).Error("failed to get device") + + return c.JSON(http.StatusForbidden, NewMessageFromError(ErrWebEndpointForbidden)) + } + + // Check namespace setting first + namespace, err := h.Client.NamespaceLookup(c.Request().Context(), endpoint.Namespace) + if err != nil { + log.WithError(err).Error("failed to get namespace") + + return c.JSON(http.StatusForbidden, NewMessageFromError(ErrWebEndpointForbidden)) + } + if namespace.Settings != nil && !namespace.Settings.AllowWebEndpoints { + log.WithFields(log.Fields{ + "namespace": endpoint.Namespace, + }).Warn("web endpoints disabled for namespace") + + return c.JSON(http.StatusForbidden, NewMessageFromError(ErrWebEndpointForbidden)) + } + + // Check device's SSH settings for AllowWebEndpoints (default: true if not set) + if device.SSH != nil && !device.SSH.AllowWebEndpoints { + log.WithFields(log.Fields{ + "device": endpoint.DeviceUID, + }).Warn("web endpoints disabled for device") + + return c.JSON(http.StatusForbidden, NewMessageFromError(ErrWebEndpointForbidden)) + } + logger := log.WithFields(log.Fields{ "request-id": requestID, "namespace": endpoint.Namespace, diff --git a/ssh/server/auth/password.go b/ssh/server/auth/password.go index 7cea216af98..d8a00b67cc0 100644 --- a/ssh/server/auth/password.go +++ b/ssh/server/auth/password.go @@ -1,6 +1,7 @@ package auth import ( + "errors" "net" gliderssh "github.com/gliderlabs/ssh" @@ -31,6 +32,12 @@ func PasswordHandler(ctx gliderssh.Context, passwd string) bool { } if err := sess.Auth(ctx, session.AuthPassword(passwd)); err != nil { + if errors.Is(err, session.ErrPasswordDisabled) { + logger.Warn("password authentication is disabled for this namespace") + + return false + } + logger.Warn("failed to authenticate on device using password") return false diff --git a/ssh/server/auth/publickey.go b/ssh/server/auth/publickey.go index 7d9fc28a6dc..c8d5e16a39f 100644 --- a/ssh/server/auth/publickey.go +++ b/ssh/server/auth/publickey.go @@ -1,6 +1,7 @@ package auth import ( + "errors" "net" gliderssh "github.com/gliderlabs/ssh" @@ -33,6 +34,12 @@ func PublicKeyHandler(ctx gliderssh.Context, publicKey gliderssh.PublicKey) bool } if err := sess.Auth(ctx, session.AuthPublicKey(publicKey)); err != nil { + if errors.Is(err, session.ErrPublicKeyDisabled) { + logger.Warn("public key authentication is disabled for this namespace") + + return false + } + logger.Warn("failed to authenticate on device using public key") return false diff --git a/ssh/server/channels/session.go b/ssh/server/channels/session.go index 80e9370fae9..6dbdb5f42c4 100644 --- a/ssh/server/channels/session.go +++ b/ssh/server/channels/session.go @@ -278,10 +278,49 @@ func DefaultSessionHandler() gliderssh.ChannelHandler { sess.Event(req.Type, req.Payload, seat) case ExecRequestType, SubsystemRequestType: + if req.Type == SubsystemRequestType { + var subsystem struct { + Subsystem string `ssh:"subsystem"` + } + if err := gossh.Unmarshal(req.Payload, &subsystem); err != nil { + reject(nil, "failed to decode subsystem request") + + return + } + if subsystem.Subsystem == "sftp" { + // Check namespace setting first + if sess.Namespace.Settings != nil && !sess.Namespace.Settings.AllowSFTP { + reject(nil, "SFTP is disabled for this namespace") + + return + } + // Check device override + if sess.Device.SSH != nil && !sess.Device.SSH.AllowSFTP { + reject(nil, "SFTP is disabled for this device") + + return + } + } + } + session.Event[models.SSHCommand](sess, req.Type, req.Payload, seat) sess.Type = ExecRequestType case PtyRequestType: + // Check namespace setting first + if sess.Namespace.Settings != nil && !sess.Namespace.Settings.AllowTTY { + reject(nil, "TTY allocation is disabled for this namespace") + + return + } + + // Check device override + if sess.Device.SSH != nil && !sess.Device.SSH.AllowTTY { + reject(nil, "TTY allocation is disabled for this device") + + return + } + var pty models.SSHPty if err := gossh.Unmarshal(req.Payload, &pty); err != nil { @@ -300,6 +339,20 @@ func DefaultSessionHandler() gliderssh.ChannelHandler { sess.Event(req.Type, dimensions, seat) //nolint:errcheck case AuthRequestOpenSSHRequest: + // Check namespace setting first + if sess.Namespace.Settings != nil && !sess.Namespace.Settings.AllowAgentForwarding { + reject(nil, "Agent forwarding is disabled for this namespace") + + return + } + + // Check device override + if sess.Device.SSH != nil && !sess.Device.SSH.AllowAgentForwarding { + reject(nil, "Agent forwarding is disabled for this device") + + return + } + gliderssh.SetAgentRequested(ctx) sess.Event(req.Type, req.Payload, seat) diff --git a/ssh/server/server.go b/ssh/server/server.go index 1eaa8d6e660..ac6a1a62855 100644 --- a/ssh/server/server.go +++ b/ssh/server/server.go @@ -110,7 +110,20 @@ func NewServer(dialer *dialer.Dialer, cache cache.Cache, opts *Options) *Server channels.SessionChannel: channels.DefaultSessionHandler(), channels.DirectTCPIPChannel: channels.DefaultDirectTCPIPHandler, }, - LocalPortForwardingCallback: func(_ gliderssh.Context, _ string, _ uint32) bool { + LocalPortForwardingCallback: func(ctx gliderssh.Context, _ string, _ uint32) bool { + sess, _ := session.ObtainSession(ctx) + if sess == nil || sess.Device == nil { + return true + } + + if sess.Namespace.Settings != nil && !sess.Namespace.Settings.AllowTCPForwarding { + return false + } + + if sess.Device.SSH != nil && !sess.Device.SSH.AllowTCPForwarding { + return false + } + return true }, ReversePortForwardingCallback: func(_ gliderssh.Context, _ string, _ uint32) bool { diff --git a/ssh/session/auther.go b/ssh/session/auther.go index 0bf9b3b5c7d..91b52bb88a5 100644 --- a/ssh/session/auther.go +++ b/ssh/session/auther.go @@ -76,11 +76,24 @@ func (*publicKeyAuth) Auth() authFunc { } func (p *publicKeyAuth) Evaluate(session *Session) error { - // Versions earlier than 0.6.0 do not validate the user when receiving a public key - // authentication request. This implies that requests with invalid users are - // treated as "authenticated" because the connection does not raise any error. - // Moreover, the agent panics after the connection ends. To avoid this, connections - // with public key are not permitted when agent version is 0.5.x or earlier + if session.Namespace.Settings != nil && !session.Namespace.Settings.AllowPublicKey { + return ErrPublicKeyDisabled + } + + if session.Device != nil && session.Device.SSH != nil && !session.Device.SSH.AllowPublicKey { + return ErrPublicKeyDisabled + } + + if session.Namespace.Settings != nil && !session.Namespace.Settings.AllowRoot { + if session.Target.Username == "root" { + return ErrRootDisabled + } + } + + if session.Device != nil && session.Device.SSH != nil && !session.Device.SSH.AllowRoot && session.Target.Username == "root" { + return ErrRootDisabled + } + if !sshconf.AllowPublickeyAccessBelow060 { version := session.Device.Info.Version if version != "latest" { @@ -137,7 +150,32 @@ func (p *passwordAuth) Auth() authFunc { } } -func (*passwordAuth) Evaluate(*Session) error { - // We don't need (yet) to do any evaluation when authenticating with password. +func (p *passwordAuth) Evaluate(session *Session) error { + if session.Namespace.Settings != nil && !session.Namespace.Settings.AllowPassword { + return ErrPasswordDisabled + } + + if session.Device != nil && session.Device.SSH != nil && !session.Device.SSH.AllowPassword { + return ErrPasswordDisabled + } + + if session.Namespace.Settings != nil && !session.Namespace.Settings.AllowRoot { + if session.Target.Username == "root" { + return ErrRootDisabled + } + } + + if session.Device != nil && session.Device.SSH != nil && !session.Device.SSH.AllowRoot && session.Target.Username == "root" { + return ErrRootDisabled + } + + if session.Namespace.Settings != nil && !session.Namespace.Settings.AllowEmptyPasswords && p.pwd == "" { + return ErrEmptyPasswordNotPermitted + } + + if session.Device != nil && session.Device.SSH != nil && !session.Device.SSH.AllowEmptyPasswords && p.pwd == "" { + return ErrEmptyPasswordNotPermitted + } + return nil } diff --git a/ssh/session/auther_test.go b/ssh/session/auther_test.go new file mode 100644 index 00000000000..ad435b4b98b --- /dev/null +++ b/ssh/session/auther_test.go @@ -0,0 +1,114 @@ +package session + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + + "github.com/shellhub-io/shellhub/pkg/api/internalclient/mocks" + "github.com/shellhub-io/shellhub/pkg/models" + "github.com/shellhub-io/shellhub/ssh/pkg/target" + "github.com/stretchr/testify/assert" + testifymock "github.com/stretchr/testify/mock" + gossh "golang.org/x/crypto/ssh" +) + +func TestPasswordAuthEvaluate(t *testing.T) { + cases := []struct { + name string + allowPassword bool + expectedError error + }{ + { + name: "password auth enabled", + allowPassword: true, + expectedError: nil, + }, + { + name: "password auth disabled", + allowPassword: false, + expectedError: ErrPasswordDisabled, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sess := &Session{ + Data: Data{ + Target: &target.Target{Username: "user"}, + Namespace: &models.Namespace{ + Settings: &models.NamespaceSettings{ + AllowPassword: tc.allowPassword, + }, + }, + }, + } + + auth := AuthPassword("password") + err := auth.Evaluate(sess) + + assert.Equal(t, tc.expectedError, err) + }) + } +} + +func TestPublicKeyAuthEvaluate(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + assert.NoError(t, err) + + publicKey, err := gossh.NewPublicKey(&privateKey.PublicKey) + assert.NoError(t, err) + + fingerprint := gossh.FingerprintLegacyMD5(publicKey) + + cases := []struct { + name string + allowPublicKey bool + mockSetup func(*mocks.Client) + expectedError error + }{ + { + name: "public key auth enabled", + allowPublicKey: true, + mockSetup: func(m *mocks.Client) { + m.On("GetPublicKey", testifymock.Anything, fingerprint, "tenant-1").Return(nil, nil) + m.On("EvaluateKey", testifymock.Anything, fingerprint, testifymock.Anything, testifymock.Anything).Return(true, nil) + }, + expectedError: nil, + }, + { + name: "public key auth disabled", + allowPublicKey: false, + mockSetup: func(*mocks.Client) {}, + expectedError: ErrPublicKeyDisabled, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mockClient := &mocks.Client{} + tc.mockSetup(mockClient) + + sess := &Session{ + Data: Data{ + Target: &target.Target{Username: "user"}, + Device: &models.Device{ + Info: &models.DeviceInfo{Version: "latest"}, + TenantID: "tenant-1", + }, + Namespace: &models.Namespace{ + Settings: &models.NamespaceSettings{ + AllowPublicKey: tc.allowPublicKey, + }, + }, + }, + api: mockClient, + } + + auth := AuthPublicKey(publicKey) + err := auth.Evaluate(sess) + + assert.Equal(t, tc.expectedError, err) + }) + } +} diff --git a/ssh/session/errors.go b/ssh/session/errors.go index efdae1f3212..347183e21a3 100644 --- a/ssh/session/errors.go +++ b/ssh/session/errors.go @@ -4,17 +4,22 @@ import "fmt" // Errors returned by the NewSession to the client. var ( - ErrBillingBlock = fmt.Errorf("Connection to this device is not available as your current namespace doesn't qualify for the free plan. To gain access, you'll need to contact the namespace owner to initiate an upgrade.\n\nFor a detailed estimate of costs based on your use-cases with ShellHub Cloud, visit our pricing page at https://www.shellhub.io/pricing. If you wish to upgrade immediately, navigate to https://cloud.shellhub.io/settings/billing. Your cooperation is appreciated.") //nolint:all - ErrFirewallBlock = fmt.Errorf("you cannot connect to this device because a firewall rule block your connection") - ErrFirewallConnection = fmt.Errorf("failed to communicate to the firewall") - ErrFirewallUnknown = fmt.Errorf("failed to evaluate the firewall rule") - ErrHost = fmt.Errorf("failed to get the device address") - ErrFindDevice = fmt.Errorf("failed to find the device") - ErrDial = fmt.Errorf("failed to connect to device agent, please check the device connection") - ErrInvalidVersion = fmt.Errorf("failed to parse device version") - ErrUnsuportedPublicKeyAuth = fmt.Errorf("connections using public keys are not permitted when the agent version is 0.5.x or earlier") - ErrUnexpectedAuthMethod = fmt.Errorf("failed to authenticate the session due to a unexpected method") - ErrEvaluatePublicKey = fmt.Errorf("failed to evaluate the provided public key") - ErrSeatAlreadySet = fmt.Errorf("this seat was already set") - ErrLicenseBlock = fmt.Errorf("Connection blocked: your ShellHub instance has exceeded the maximum number of devices allowed by your license. Please contact support or remove unused devices.") //nolint:all + ErrBillingBlock = fmt.Errorf("Connection to this device is not available as your current namespace doesn't qualify for the free plan. To gain access, you'll need to contact the namespace owner to initiate an upgrade.\n\nFor a detailed estimate of costs based on your use-cases with ShellHub Cloud, visit our pricing page at https://www.shellhub.io/pricing. If you wish to upgrade immediately, navigate to https://cloud.shellhub.io/settings/billing. Your cooperation is appreciated.") //nolint:all + ErrFirewallBlock = fmt.Errorf("you cannot connect to this device because a firewall rule block your connection") + ErrFirewallConnection = fmt.Errorf("failed to communicate to the firewall") + ErrFirewallUnknown = fmt.Errorf("failed to evaluate the firewall rule") + ErrHost = fmt.Errorf("failed to get the device address") + ErrFindDevice = fmt.Errorf("failed to find the device") + ErrDial = fmt.Errorf("failed to connect to device agent, please check the device connection") + ErrInvalidVersion = fmt.Errorf("failed to parse device version") + ErrUnsuportedPublicKeyAuth = fmt.Errorf("connections using public keys are not permitted when the agent version is 0.5.x or earlier") + ErrUnexpectedAuthMethod = fmt.Errorf("failed to authenticate the session due to a unexpected method") + ErrEvaluatePublicKey = fmt.Errorf("failed to evaluate the provided public key") + ErrPasswordDisabled = fmt.Errorf("password authentication is disabled for this namespace") + ErrPublicKeyDisabled = fmt.Errorf("public key authentication is disabled for this namespace") + ErrPublicKeyNotFound = fmt.Errorf("public key not found") + ErrSeatAlreadySet = fmt.Errorf("this seat was already set") + ErrLicenseBlock = fmt.Errorf("Connection blocked: your ShellHub instance has exceeded the maximum number of devices allowed by your license. Please contact support or remove unused devices.") //nolint:all + ErrRootDisabled = fmt.Errorf("root login is disabled for this device") + ErrEmptyPasswordNotPermitted = fmt.Errorf("empty passwords are not permitted for this device") ) diff --git a/ui-react/apps/console/src/components/common/SettingToggle.tsx b/ui-react/apps/console/src/components/common/SettingToggle.tsx new file mode 100644 index 00000000000..848434a4a85 --- /dev/null +++ b/ui-react/apps/console/src/components/common/SettingToggle.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; + +const TOGGLE_STYLES = { + primary: { + on: "bg-primary/15 text-primary border border-primary/25", + off: "bg-hover-strong text-text-secondary border border-border-light", + }, + success: { + on: "bg-accent-green/15 text-accent-green border border-accent-green/25", + off: "bg-hover-strong text-text-secondary border border-border-light", + }, + danger: { + on: "bg-accent-red/15 text-accent-red border border-accent-red/25", + off: "bg-hover-strong text-text-secondary border border-border-light", + }, +} as const; + +export type SettingToggleTone = keyof typeof TOGGLE_STYLES; + +interface SettingToggleProps { + checked: boolean; + disabled?: boolean; + tone?: SettingToggleTone; + onChange: (checked: boolean) => Promise | void; +} + +export default function SettingToggle({ + checked, + disabled = false, + tone = "primary", + onChange, +}: SettingToggleProps) { + const [loading, setLoading] = useState(false); + const styles = TOGGLE_STYLES[tone]; + + const handleToggle = async (value: boolean) => { + if (loading || disabled) return; + setLoading(true); + try { + await onChange(value); + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+ ); +} diff --git a/ui-react/apps/console/src/hooks/useDeviceMutations.ts b/ui-react/apps/console/src/hooks/useDeviceMutations.ts index fb72f02bf0b..d94c89e6c6d 100644 --- a/ui-react/apps/console/src/hooks/useDeviceMutations.ts +++ b/ui-react/apps/console/src/hooks/useDeviceMutations.ts @@ -42,6 +42,14 @@ export function useRenameDevice() { }); } +export function useUpdateDeviceSSH() { + const invalidate = useInvalidateByIds("getDevices", "getDevice", "getStatusDevices"); + return useMutation({ + ...updateDeviceMutation(), + onSuccess: invalidate, + }); +} + export function useAddDeviceTag() { const invalidate = useInvalidateByIds("getDevices", "getDevice", "getStatusDevices", "getTags"); return useMutation({ diff --git a/ui-react/apps/console/src/pages/BannerEdit.tsx b/ui-react/apps/console/src/pages/BannerEdit.tsx index 0a1ec5afeff..5744a835db3 100644 --- a/ui-react/apps/console/src/pages/BannerEdit.tsx +++ b/ui-react/apps/console/src/pages/BannerEdit.tsx @@ -6,6 +6,7 @@ import type { Namespace } from "../hooks/useNamespaces"; import { useEditNamespace } from "../hooks/useNamespaceMutations"; import { useAuthStore } from "../stores/authStore"; import { useHasPermission } from "../hooks/useHasPermission"; +import { normalizeNamespaceSettings } from "../utils/namespaceSettings"; const MAX_LENGTH = 4096; @@ -30,7 +31,7 @@ function BannerEditor({ ns, canEdit }: { ns: Namespace; canEdit: boolean }) { try { await editNs.mutateAsync({ path: { tenant: ns.tenant_id }, - body: { settings: { connection_announcement: text, session_record: ns.settings?.session_record ?? false } }, + body: { settings: normalizeNamespaceSettings({ ...ns.settings, connection_announcement: text }) }, }); void navigate("/settings"); } catch { diff --git a/ui-react/apps/console/src/pages/DeviceDetails.tsx b/ui-react/apps/console/src/pages/DeviceDetails.tsx index 815eb1e5556..d803419462c 100644 --- a/ui-react/apps/console/src/pages/DeviceDetails.tsx +++ b/ui-react/apps/console/src/pages/DeviceDetails.tsx @@ -18,6 +18,8 @@ import { ClockIcon, CpuChipIcon, ChevronDoubleRightIcon, + LockOpenIcon, + LockClosedIcon, } from "@heroicons/react/24/outline"; import { useDevice } from "../hooks/useDevice"; import { @@ -25,6 +27,7 @@ import { useAddDeviceTag, useRemoveDeviceTag, useRemoveDevice, + useUpdateDeviceSSH, } from "../hooks/useDeviceMutations"; import { useNamespace } from "../hooks/useNamespaces"; import { useAuthStore } from "../stores/authStore"; @@ -33,10 +36,12 @@ import DeviceActionDialog from "./devices/DeviceActionDialog"; import ConnectDrawer from "../components/ConnectDrawer"; import CopyButton from "../components/common/CopyButton"; import PlatformBadge from "../components/common/PlatformBadge"; +import SettingToggle from "../components/common/SettingToggle"; import { formatDateFull, formatRelative } from "../utils/date"; import { buildSshid } from "../utils/sshid"; import { useHasPermission } from "../hooks/useHasPermission"; import RestrictedAction from "../components/common/RestrictedAction"; +import type { Device } from "../client"; /* ─── Shared styles ─── */ const LABEL @@ -120,7 +125,7 @@ function TagsSection({ uid, tags }: { uid: string; tags: string[] }) { try { await removeTagMutation.mutateAsync({ path: { uid, name: tag } }); } catch { - /* invalidation handles UI update */ + return; } }; @@ -278,8 +283,11 @@ export default function DeviceDetails() { const [searchParams] = useSearchParams(); const { device, isLoading } = useDevice(uid ?? ""); const removeMutation = useRemoveDevice(); + const updateSSH = useUpdateDeviceSSH(); const tenantId = useAuthStore((s) => s.tenant) ?? ""; const { namespace: currentNamespace } = useNamespace(tenantId); + type DeviceSSHSettings = NonNullable; + const deviceSettings = device?.settings; const existingSession = useTerminalStore((s) => s.sessions.find((sess) => sess.deviceUid === uid), ); @@ -292,7 +300,20 @@ export default function DeviceDetails() { action: "accept" | "reject" | "remove"; } | null>(null); - // Auto-open connect drawer if ?connect=true (adjust during render) + const updateDeviceSetting = async (settings: Partial) => { + if (!device) { + return; + } + + await updateSSH.mutateAsync({ + path: { uid: device.uid }, + body: { + name: device.name, + settings, + }, + }); + }; + const shouldAutoConnect = searchParams.get("connect") === "true" && device?.online @@ -307,7 +328,6 @@ export default function DeviceDetails() { setAutoConnectDone(false); } - // Restore existing terminal session (side effect only, no setState) useEffect(() => { if ( searchParams.get("connect") === "true" @@ -593,6 +613,250 @@ export default function DeviceDetails() { + {/* Settings */} +
+

+ + Settings +

+
+
+
+ + {deviceSettings?.allow_password ?? true + ? + : } + +
+

+ Allow Password Authentication +

+

+ Allow SSH connections using password for this device +

+
+
+
+ { + return updateDeviceSetting({ allow_password: checked }); + }} + /> +
+
+
+
+ + {deviceSettings?.allow_public_key ?? true + ? + : } + +
+

+ Allow Public Key Authentication +

+

+ Allow SSH connections using public key for this device +

+
+
+
+ { + return updateDeviceSetting({ allow_public_key: checked }); + }} + /> +
+
+
+
+ + {deviceSettings?.allow_root ?? true + ? + : } + +
+

+ Allow Root Login +

+

+ Allow SSH connections as root user for this device +

+
+
+
+ { + return updateDeviceSetting({ allow_root: checked }); + }} + /> +
+
+
+
+ + {deviceSettings?.allow_empty_passwords ?? false + ? + : } + +
+

+ Allow Empty Passwords +

+

+ Allow SSH connections with empty passwords for this device +

+
+
+
+ { + return updateDeviceSetting({ allow_empty_passwords: checked }); + }} + /> +
+
+
+
+ + {deviceSettings?.allow_tty ?? true + ? + : } + +
+

+ Allow TTY Allocation +

+

+ Allow terminal (TTY) allocation for this device +

+
+
+
+ { + return updateDeviceSetting({ allow_tty: checked }); + }} + /> +
+
+
+
+ + {deviceSettings?.allow_tcp_forwarding ?? true + ? + : } + +
+

+ Allow TCP Forwarding +

+

+ Allow TCP port forwarding for this device +

+
+
+
+ { + return updateDeviceSetting({ allow_tcp_forwarding: checked }); + }} + /> +
+
+
+
+ + {deviceSettings?.allow_web_endpoints ?? true + ? + : } + +
+

+ Allow Web Endpoints +

+

+ Allow HTTP/HTTPS access via ShellHub proxy +

+
+
+
+ { + return updateDeviceSetting({ allow_web_endpoints: checked }); + }} + /> +
+
+
+
+ + {deviceSettings?.allow_sftp ?? true + ? + : } + +
+

+ Allow SFTP +

+

+ Allow SFTP subsystem for this device +

+
+
+
+ { + return updateDeviceSetting({ allow_sftp: checked }); + }} + /> +
+
+
+
+ + {deviceSettings?.allow_agent_forwarding ?? false + ? + : } + +
+

+ Allow Agent Forwarding +

+

+ Allow SSH agent forwarding for this device +

+
+
+
+ { + return updateDeviceSetting({ allow_agent_forwarding: checked }); + }} + /> +
+
+
+
+ {/* Delete Dialog */} {showDelete && (
diff --git a/ui-react/apps/console/src/pages/Settings.tsx b/ui-react/apps/console/src/pages/Settings.tsx index 105e3834378..03a332ba2f0 100644 --- a/ui-react/apps/console/src/pages/Settings.tsx +++ b/ui-react/apps/console/src/pages/Settings.tsx @@ -11,10 +11,13 @@ import { TagIcon, FingerPrintIcon, VideoCameraIcon, + LockClosedIcon, + LockOpenIcon, TrashIcon, ArrowRightStartOnRectangleIcon, } from "@heroicons/react/24/outline"; import { useNamespace } from "../hooks/useNamespaces"; +import type { Namespace } from "../hooks/useNamespaces"; import { useEditNamespace, useDeleteNamespace, useLeaveNamespace } from "../hooks/useNamespaceMutations"; import { useAuthStore } from "../stores/authStore"; import { useHasPermission } from "../hooks/useHasPermission"; @@ -22,8 +25,12 @@ import PageHeader from "../components/common/PageHeader"; import CopyButton from "../components/common/CopyButton"; import Drawer from "../components/common/Drawer"; import ConfirmDialog from "../components/common/ConfirmDialog"; +import SettingToggle from "../components/common/SettingToggle"; import { LABEL, INPUT } from "../utils/styles"; import { getConfig } from "../env"; +import { normalizeNamespaceSettings } from "../utils/namespaceSettings"; + +type NamespaceSettings = NonNullable; const NAME_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; @@ -412,32 +419,92 @@ export default function Settings() { const [editNameOpen, setEditNameOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [leaveOpen, setLeaveOpen] = useState(false); - const [togglingRecord, setTogglingRecord] = useState(false); + const [togglingSetting, setTogglingSetting] = useState(null); const canRename = useHasPermission("namespace:rename"); const canUpdateRecording = useHasPermission("namespace:updateSessionRecording"); + const canUpdateAllowPassword = useHasPermission("namespace:updateAllowPassword"); + const canUpdateAllowPublicKey = useHasPermission("namespace:updateAllowPublicKey"); + const canUpdateAllowRoot = useHasPermission("namespace:updateAllowRoot"); + const canUpdateAllowEmptyPasswords = useHasPermission("namespace:updateAllowEmptyPasswords"); + const canUpdateAllowTTY = useHasPermission("namespace:updateAllowTTY"); + const canUpdateAllowTcpForwarding = useHasPermission("namespace:updateAllowTcpForwarding"); + const canUpdateAllowWebEndpoints = useHasPermission("namespace:updateAllowWebEndpoints"); + const canUpdateAllowSFTP = useHasPermission("namespace:updateAllowSFTP"); + const canUpdateAllowAgentForwarding = useHasPermission("namespace:updateAllowAgentForwarding"); const canEditBanner = useHasPermission("namespace:editBanner"); const canDelete = useHasPermission("namespace:delete"); - const settings = ns?.settings; - const sessionRecord = settings?.session_record ?? false; - const banner = settings?.connection_announcement ?? ""; - - const handleToggleRecord = async () => { - if (!tenantId || togglingRecord) return; - setTogglingRecord(true); + const settings = normalizeNamespaceSettings(ns?.settings); + const sessionRecord = settings.session_record; + const allowPassword = settings.allow_password ?? true; + const allowPublicKey = settings.allow_public_key ?? true; + const allowRoot = settings.allow_root ?? true; + const allowEmptyPasswords = settings.allow_empty_passwords ?? false; + const allowTTY = settings.allow_tty ?? true; + const allowTcpForwarding = settings.allow_tcp_forwarding ?? true; + const allowWebEndpoints = settings.allow_web_endpoints ?? true; + const allowSFTP = settings.allow_sftp ?? true; + const allowAgentForwarding = settings.allow_agent_forwarding ?? false; + const banner = settings.connection_announcement; + + const updateNamespaceSettings = async (patch: Partial, key: keyof NamespaceSettings) => { + if (!tenantId || togglingSetting) return; + setTogglingSetting(key); try { await editNs.mutateAsync({ path: { tenant: tenantId }, - body: { settings: { session_record: !sessionRecord, connection_announcement: banner } }, + body: { settings: normalizeNamespaceSettings({ ...settings, ...patch }) }, }); } catch { /* state didn't change */ } finally { - setTogglingRecord(false); + setTogglingSetting(null); } }; + const handleToggleRecord = async () => { + await updateNamespaceSettings({ session_record: !sessionRecord }, "session_record"); + }; + + const handleToggleAllowPassword = async () => { + await updateNamespaceSettings({ allow_password: !allowPassword }, "allow_password"); + }; + + const handleToggleAllowPublicKey = async () => { + await updateNamespaceSettings({ allow_public_key: !allowPublicKey }, "allow_public_key"); + }; + + const handleToggleAllowRoot = async () => { + await updateNamespaceSettings({ allow_root: !allowRoot }, "allow_root"); + }; + + const handleToggleAllowEmptyPasswords = async () => { + await updateNamespaceSettings({ allow_empty_passwords: !allowEmptyPasswords }, "allow_empty_passwords"); + }; + + const handleToggleAllowTTY = async () => { + await updateNamespaceSettings({ allow_tty: !allowTTY }, "allow_tty"); + }; + + const handleToggleAllowTcpForwarding = async () => { + await updateNamespaceSettings({ allow_tcp_forwarding: !allowTcpForwarding }, "allow_tcp_forwarding"); + }; + + const handleToggleAllowWebEndpoints = async () => { + await updateNamespaceSettings({ allow_web_endpoints: !allowWebEndpoints }, "allow_web_endpoints"); + }; + + const handleToggleAllowSFTP = async () => { + await updateNamespaceSettings({ allow_sftp: !allowSFTP }, "allow_sftp"); + }; + + const handleToggleAllowAgentForwarding = async () => { + await updateNamespaceSettings({ allow_agent_forwarding: !allowAgentForwarding }, "allow_agent_forwarding"); + }; + + const isUpdatingSetting = (key: keyof NamespaceSettings) => togglingSetting === key; + if (!ns) { return (
@@ -518,41 +585,137 @@ export default function Settings() { title="Session Recording" description="Record SSH sessions for audit and playback" > -
- - -
+ handleToggleRecord()} + /> )} {/* SSH Banner */} + + {/* Allow SSH password */} + : } + title="Allow Password Authentication" + description="Allow SSH connections using password for all devices in this namespace" + > + handleToggleAllowPassword()} + /> + + + {/* Allow SSH public key */} + : } + title="Allow Public Key Authentication" + description="Allow SSH connections using public key for all devices in this namespace" + > + handleToggleAllowPublicKey()} + /> + + + : } + title="Allow Root Login" + description="Allow SSH connections to devices using the root user" + > + handleToggleAllowRoot()} + /> + + + : } + title="Allow Empty Passwords" + description="Allow SSH logins with empty passwords for devices in this namespace" + > + handleToggleAllowEmptyPasswords()} + /> + + + : } + title="Allow TTY Allocation" + description="Allow SSH sessions to allocate a TTY" + > + handleToggleAllowTTY()} + /> + + + : } + title="Allow TCP Forwarding" + description="Allow SSH TCP port forwarding for devices in this namespace" + > + handleToggleAllowTcpForwarding()} + /> + + + : } + title="Allow Web Endpoints" + description="Allow access to web endpoints through the HTTP proxy" + > + handleToggleAllowWebEndpoints()} + /> + + + : } + title="Allow SFTP" + description="Allow the SFTP subsystem for devices in this namespace" + > + handleToggleAllowSFTP()} + /> + + + : } + title="Allow Agent Forwarding" + description="Allow SSH agent forwarding for devices in this namespace" + > + handleToggleAllowAgentForwarding()} + /> + + {/* ── Danger Zone ── */} diff --git a/ui-react/apps/console/src/pages/admin/namespaces/EditNamespaceDrawer.tsx b/ui-react/apps/console/src/pages/admin/namespaces/EditNamespaceDrawer.tsx index 3f3b5ec7e80..692e2f661dc 100644 --- a/ui-react/apps/console/src/pages/admin/namespaces/EditNamespaceDrawer.tsx +++ b/ui-react/apps/console/src/pages/admin/namespaces/EditNamespaceDrawer.tsx @@ -5,6 +5,7 @@ import { isSdkError } from "../../../api/errors"; import Drawer from "../../../components/common/Drawer"; import { LABEL, INPUT } from "../../../utils/styles"; import type { Namespace } from "../../../client"; +import { normalizeNamespaceSettings } from "../../../utils/namespaceSettings"; interface EditNamespaceDrawerProps { open: boolean; @@ -40,16 +41,15 @@ export default function EditNamespaceDrawer({ try { await editNamespace.mutateAsync({ path: { tenantID: namespace.tenant_id }, - // The SDK types body as full Namespace; we spread the original - // to satisfy the type while only changing the editable fields. body: { ...namespace, name: name.trim(), max_devices: maxDevices, settings: { - connection_announcement: - namespace.settings?.connection_announcement ?? "", - session_record: sessionRecord, + ...normalizeNamespaceSettings({ + ...namespace.settings, + session_record: sessionRecord, + }), }, }, }); diff --git a/ui-react/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx b/ui-react/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx index 934695bc808..0fdacfdf369 100644 --- a/ui-react/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx +++ b/ui-react/apps/console/src/pages/admin/namespaces/__tests__/EditNamespaceDrawer.test.tsx @@ -20,7 +20,7 @@ vi.mock("../../../../components/common/Drawer", async () => ({ const mockMutateAsync = vi.fn(); -const mockNamespace: Namespace = { +const mockNamespace = { name: "my-namespace", owner: "owner-1", tenant_id: "tenant-abc", @@ -28,6 +28,8 @@ const mockNamespace: Namespace = { settings: { session_record: true, connection_announcement: "hello", + allow_password: true, + allow_public_key: true, }, max_devices: 10, created_at: "2024-01-01T00:00:00Z", @@ -35,7 +37,7 @@ const mockNamespace: Namespace = { devices_pending_count: 0, devices_accepted_count: 3, devices_rejected_count: 0, -}; +} as unknown as Namespace; beforeEach(() => { vi.clearAllMocks(); diff --git a/ui-react/apps/console/src/utils/__tests__/permission.test.ts b/ui-react/apps/console/src/utils/__tests__/permission.test.ts index d8012367717..e6fa87a1f69 100644 --- a/ui-react/apps/console/src/utils/__tests__/permission.test.ts +++ b/ui-react/apps/console/src/utils/__tests__/permission.test.ts @@ -22,6 +22,11 @@ const ADMINISTRATOR_ACTIONS: Action[] = [ "namespace:addMember", "namespace:editMember", "namespace:removeMember", "namespace:editInvitation", "namespace:cancelInvitation", "namespace:updateSessionRecording", "namespace:editBanner", + "namespace:updateAllowPassword", "namespace:updateAllowPublicKey", + "namespace:updateAllowRoot", "namespace:updateAllowEmptyPasswords", + "namespace:updateAllowTTY", "namespace:updateAllowTcpForwarding", + "namespace:updateAllowWebEndpoints", "namespace:updateAllowSFTP", + "namespace:updateAllowAgentForwarding", "publicKey:create", "publicKey:edit", "publicKey:remove", "firewall:create", "firewall:edit", "firewall:remove", "webEndpoint:create", "webEndpoint:delete", diff --git a/ui-react/apps/console/src/utils/namespaceSettings.ts b/ui-react/apps/console/src/utils/namespaceSettings.ts new file mode 100644 index 00000000000..49e6f3ad554 --- /dev/null +++ b/ui-react/apps/console/src/utils/namespaceSettings.ts @@ -0,0 +1,19 @@ +import type { NamespaceSettings } from "../client"; + +export function normalizeNamespaceSettings( + settings?: Partial | null, +): NamespaceSettings { + return { + session_record: settings?.session_record ?? false, + connection_announcement: settings?.connection_announcement ?? "", + allow_password: settings?.allow_password ?? true, + allow_public_key: settings?.allow_public_key ?? true, + allow_root: settings?.allow_root ?? true, + allow_empty_passwords: settings?.allow_empty_passwords ?? false, + allow_tty: settings?.allow_tty ?? true, + allow_tcp_forwarding: settings?.allow_tcp_forwarding ?? true, + allow_web_endpoints: settings?.allow_web_endpoints ?? true, + allow_sftp: settings?.allow_sftp ?? true, + allow_agent_forwarding: settings?.allow_agent_forwarding ?? false, + } as NamespaceSettings; +} diff --git a/ui-react/apps/console/src/utils/permission.ts b/ui-react/apps/console/src/utils/permission.ts index d176c005e30..ee415dc969b 100644 --- a/ui-react/apps/console/src/utils/permission.ts +++ b/ui-react/apps/console/src/utils/permission.ts @@ -44,6 +44,15 @@ const permissions = { "namespace:editInvitation": RoleLevel.ADMINISTRATOR, "namespace:cancelInvitation": RoleLevel.ADMINISTRATOR, "namespace:updateSessionRecording": RoleLevel.ADMINISTRATOR, + "namespace:updateAllowPassword": RoleLevel.ADMINISTRATOR, + "namespace:updateAllowPublicKey": RoleLevel.ADMINISTRATOR, + "namespace:updateAllowRoot": RoleLevel.ADMINISTRATOR, + "namespace:updateAllowEmptyPasswords": RoleLevel.ADMINISTRATOR, + "namespace:updateAllowTTY": RoleLevel.ADMINISTRATOR, + "namespace:updateAllowTcpForwarding": RoleLevel.ADMINISTRATOR, + "namespace:updateAllowWebEndpoints": RoleLevel.ADMINISTRATOR, + "namespace:updateAllowSFTP": RoleLevel.ADMINISTRATOR, + "namespace:updateAllowAgentForwarding": RoleLevel.ADMINISTRATOR, "namespace:delete": RoleLevel.OWNER, // Tags diff --git a/ui-react/package-lock.json b/ui-react/package-lock.json index dac64139d2c..7196b772704 100644 --- a/ui-react/package-lock.json +++ b/ui-react/package-lock.json @@ -166,6 +166,7 @@ "version": "7.29.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -483,6 +484,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -519,6 +521,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1847,6 +1850,7 @@ "node_modules/@tanstack/react-query": { "version": "5.91.2", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.91.2" }, @@ -1957,8 +1961,7 @@ "node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/asn1": { "version": "0.2.4", @@ -2064,6 +2067,7 @@ "version": "18.3.28", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2073,6 +2077,7 @@ "version": "18.3.7", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2090,6 +2095,7 @@ "version": "8.57.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", @@ -2125,7 +2131,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -2149,7 +2154,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.1", "@typescript-eslint/types": "^8.57.1", @@ -2170,7 +2174,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1" @@ -2187,7 +2190,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2203,7 +2205,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2216,7 +2217,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/project-service": "8.57.1", "@typescript-eslint/tsconfig-utils": "8.57.1", @@ -2243,7 +2243,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" @@ -2260,7 +2259,6 @@ "version": "7.7.4", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -2595,6 +2593,7 @@ "version": "8.16.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3047,6 +3046,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3720,8 +3720,7 @@ "node_modules/dom-accessibility-api": { "version": "0.5.16", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/domain-browser": { "version": "4.22.0", @@ -3897,6 +3896,7 @@ "version": "10.0.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -4827,6 +4827,7 @@ "version": "1.21.7", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4997,7 +4998,6 @@ "version": "1.5.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5573,6 +5573,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5726,7 +5727,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5862,6 +5862,7 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5872,6 +5873,7 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5883,8 +5885,7 @@ "node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -6232,6 +6233,7 @@ "node_modules/seroval": { "version": "1.5.1", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -6387,6 +6389,7 @@ "node_modules/solid-js": { "version": "1.9.11", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -6662,6 +6665,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6791,6 +6795,7 @@ "node_modules/typescript": { "version": "5.9.3", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6933,6 +6938,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7039,6 +7045,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7392,6 +7399,7 @@ "version": "4.3.6", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui/src/App.vue b/ui/src/App.vue index 89555022e3b..de31a67ce49 100755 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -15,7 +15,10 @@ href="/" class="new-ui-bar-link" > - + mdi-react useNewUI() → diff --git a/ui/src/api/client/api.ts b/ui/src/api/client/api.ts index 003d0fdd60f..ff2afdf11af 100644 --- a/ui/src/api/client/api.ts +++ b/ui/src/api/client/api.ts @@ -377,6 +377,45 @@ export interface CreateWebEndpointRequest { 'ttl': number; 'tls'?: WebendpointTLS; } +export interface DeviceSsh { + /** + * Allow password authentication at device level. + */ + 'allow_password'?: boolean; + /** + * Allow public key authentication at device level. + */ + 'allow_public_key'?: boolean; + /** + * Allow root user login at device level. + */ + 'allow_root'?: boolean; + /** + * Allow empty passwords at device level. + */ + 'allow_empty_passwords'?: boolean; + /** + * Allow TTY allocation at device level. + */ + 'allow_tty'?: boolean; + /** + * Allow TCP port forwarding at device level. + */ + 'allow_tcp_forwarding'?: boolean; + /** + * Allow web endpoints access via HTTP proxy at device level. + */ + 'allow_web_endpoints'?: boolean; + /** + * Allow SFTP subsystem at device level. + */ + 'allow_sftp'?: boolean; + /** + * Allow SSH agent forwarding at device level. + */ + 'allow_agent_forwarding'?: boolean; +} + export interface Device { /** * Device\'s UID @@ -426,6 +465,10 @@ export interface Device { * Device\'s Tags list */ 'tags'?: Array; + /** + * Device's SSH configuration settings + */ + 'settings'?: DeviceSsh; /** * Device\'s public URL status. */ @@ -1729,6 +1772,50 @@ export interface NamespaceSettings { * A connection announcement is a custom string written during a session when a connection is established on a device within the namespace. */ 'connection_announcement'?: string; + /** + * Disable password authentication for the namespace. + */ + 'disable_password'?: boolean; + /** + * Disable public key authentication for the namespace. + */ + 'disable_public_key'?: boolean; + /** + * Allow password authentication at namespace level. + */ + 'allow_password'?: boolean; + /** + * Allow public key authentication at namespace level. + */ + 'allow_public_key'?: boolean; + /** + * Allow root user login at namespace level. + */ + 'allow_root'?: boolean; + /** + * Allow empty passwords at namespace level. + */ + 'allow_empty_passwords'?: boolean; + /** + * Allow TTY allocation at namespace level. + */ + 'allow_tty'?: boolean; + /** + * Allow TCP port forwarding at namespace level. + */ + 'allow_tcp_forwarding'?: boolean; + /** + * Allow web endpoints access via HTTP proxy at namespace level. + */ + 'allow_web_endpoints'?: boolean; + /** + * Allow SFTP subsystem at namespace level. + */ + 'allow_sftp'?: boolean; + /** + * Allow SSH agent forwarding at namespace level. + */ + 'allow_agent_forwarding'?: boolean; } /** * @type PublicKeyFilter @@ -2116,6 +2203,10 @@ export interface UpdateDeviceRequest { * Device\'s public URL status. */ 'public_url'?: boolean; + /** + * Device's SSH configuration settings + */ + 'settings'?: DeviceSsh; } export const UpdateDeviceStatusStatusParameter = { @@ -33084,6 +33175,3 @@ export class WebEndpointsApi extends BaseAPI { return WebEndpointsApiFp(this.configuration).listWebEndpoints(filter, page, perPage, sortBy, orderBy, options).then((request) => request(this.axios, this.basePath)); } } - - - diff --git a/ui/src/components/Setting/SettingDisablePassword.vue b/ui/src/components/Setting/SettingDisablePassword.vue new file mode 100644 index 00000000000..8ba34de0d2a --- /dev/null +++ b/ui/src/components/Setting/SettingDisablePassword.vue @@ -0,0 +1,47 @@ + + + diff --git a/ui/src/components/Setting/SettingDisablePublicKey.vue b/ui/src/components/Setting/SettingDisablePublicKey.vue new file mode 100644 index 00000000000..c32fbe498bc --- /dev/null +++ b/ui/src/components/Setting/SettingDisablePublicKey.vue @@ -0,0 +1,47 @@ + + + diff --git a/ui/src/components/Setting/SettingNamespace.vue b/ui/src/components/Setting/SettingNamespace.vue index 4e5ef3b5c22..979b9f854af 100644 --- a/ui/src/components/Setting/SettingNamespace.vue +++ b/ui/src/components/Setting/SettingNamespace.vue @@ -158,12 +158,36 @@ data-test="record-item" > + + + + + + diff --git a/ui/src/components/Terminal/TerminalLoginForm.vue b/ui/src/components/Terminal/TerminalLoginForm.vue index 543d0ecd205..96e6501de0c 100644 --- a/ui/src/components/Terminal/TerminalLoginForm.vue +++ b/ui/src/components/Terminal/TerminalLoginForm.vue @@ -33,7 +33,7 @@