Add SSH-managed node registry with connection testing and reauth.

Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
2026-07-18 16:39:10 +02:00
parent b93b7519ec
commit f4dc8f63d7
31 changed files with 4801 additions and 223 deletions
+275 -5
View File
@@ -64,9 +64,14 @@ func TestUsersDeleteRejectsLastAdministrator(t *testing.T) {
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=test-user-id", nil),
httptest.NewRequest(
http.MethodDelete,
"/api/v1/users?id=test-user-id",
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@@ -126,11 +131,13 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
}
router := NewRouter(configDir)
reauthBody := `{"current_password":"correct horse battery staple extra"}`
deleteOperator := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", nil),
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", strings.NewReader(reauthBody)),
cookie,
)
deleteOperator.Header.Set("Content-Type", "application/json")
operatorRec := httptest.NewRecorder()
router.ServeHTTP(operatorRec, deleteOperator)
if operatorRec.Code != http.StatusOK {
@@ -138,9 +145,10 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
}
deleteSecondAdmin := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", nil),
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", strings.NewReader(reauthBody)),
cookie,
)
deleteSecondAdmin.Header.Set("Content-Type", "application/json")
adminRec := httptest.NewRecorder()
router.ServeHTTP(adminRec, deleteSecondAdmin)
if adminRec.Code != http.StatusOK {
@@ -182,13 +190,275 @@ func TestUsersDeleteUnknownUserNotFound(t *testing.T) {
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=missing", nil),
httptest.NewRequest(
http.MethodDelete,
"/api/v1/users?id=missing",
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code)
t.Fatalf("expected status %d, got %d body=%s", http.StatusNotFound, recorder.Code, recorder.Body.String())
}
}
func TestUsersCreateAndPatch(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Groups = append(payload.Groups, settings.Group{
Name: "Operators",
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Ops",
Permissions: []string{"nodes.read"},
})
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":["Operators"]}`),
),
cookie,
)
createReq.Header.Set("Content-Type", "application/json")
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create: expected %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String())
}
var created usersResponseTest
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
t.Fatalf("decode create: %v", err)
}
var aliceID string
for _, user := range created.Users {
if user.Username == "alice" {
aliceID = user.ID
if len(user.GroupNames) != 1 || user.GroupNames[0] != "Operators" {
t.Fatalf("unexpected groups: %#v", user.GroupNames)
}
}
}
if aliceID == "" {
t.Fatal("alice not found after create")
}
patchReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/users?id="+aliceID,
strings.NewReader(`{"disable_totp":true,"password":"another strong passphrase here"}`),
),
cookie,
)
patchReq.Header.Set("Content-Type", "application/json")
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusOK {
t.Fatalf("patch: expected %d, got %d body=%s", http.StatusOK, patchRec.Code, patchRec.Body.String())
}
store, err := settings.LoadPasswords(configDir, key)
if err != nil {
t.Fatalf("LoadPasswords: %v", err)
}
for _, user := range store.Users {
if user.ID != aliceID {
continue
}
if user.TOTPConfirmed || user.TOTPSecret != "" {
t.Fatal("expected TOTP cleared")
}
ok, err := auth.VerifyPassword("another strong passphrase here", user.PasswordHash)
if err != nil || !ok {
t.Fatal("expected password updated")
}
}
}
func TestUsersWriteRequiresPermission(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
// Strip users.manage from Administrators
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
filtered := make([]string, 0)
for _, permission := range payload.Groups[index].Permissions {
if permission == "users.manage" {
continue
}
filtered = append(filtered, permission)
}
payload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
_ = key
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"bob","password":"correct horse battery staple extra"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
}
}
func TestUsersPatchRequiresGraceReauth(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Security.ReauthSensitiveActions = true
payload.Security.ReauthGraceMinutes = 0
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
// Age the session's LastReauthAt so grace (0) always fails unless MarkReauth just ran.
store, err := settings.LoadSessionsOrEmpty(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
for index := range store.Sessions {
store.Sessions[index].LastReauthAt = time.Now().UTC().Add(-time.Minute)
}
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
patchReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/users?id=test-user-id",
strings.NewReader(`{"password":"another strong passphrase here"}`),
),
cookie,
)
patchReq.Header.Set("Content-Type", "application/json")
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusUnauthorized {
t.Fatalf("expected reauth_required status %d, got %d body=%s", http.StatusUnauthorized, patchRec.Code, patchRec.Body.String())
}
var errPayload apiErrorResponseTest
if err := json.NewDecoder(patchRec.Body).Decode(&errPayload); err != nil {
t.Fatalf("decode: %v", err)
}
if errPayload.Error != "reauth_required" {
t.Fatalf("expected reauth_required, got %q", errPayload.Error)
}
reauthReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/reauth",
strings.NewReader(`{"password":"correct horse battery staple extra"}`),
),
cookie,
)
reauthReq.Header.Set("Content-Type", "application/json")
reauthRec := httptest.NewRecorder()
router.ServeHTTP(reauthRec, reauthReq)
if reauthRec.Code != http.StatusOK {
t.Fatalf("reauth: expected %d, got %d body=%s", http.StatusOK, reauthRec.Code, reauthRec.Body.String())
}
// Refresh cookie if rotated
for _, setCookie := range reauthRec.Result().Cookies() {
if setCookie.Name == auth.SessionCookieName {
cookie = setCookie
}
}
retryReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/users?id=test-user-id",
strings.NewReader(`{"password":"another strong passphrase here"}`),
),
cookie,
)
retryReq.Header.Set("Content-Type", "application/json")
retryRec := httptest.NewRecorder()
router.ServeHTTP(retryRec, retryReq)
if retryRec.Code != http.StatusOK {
t.Fatalf("retry patch: expected %d, got %d body=%s", http.StatusOK, retryRec.Code, retryRec.Body.String())
}
}
func TestMePasswordChange(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(
http.MethodPut,
"/api/v1/me/password",
strings.NewReader(`{"current_password":"correct horse battery staple extra","new_password":"fresh strong passphrase words"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
// Session should be invalidated
meReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), cookie)
meRec := httptest.NewRecorder()
router.ServeHTTP(meRec, meReq)
if meRec.Code != http.StatusUnauthorized {
t.Fatalf("expected session invalidated, got %d", meRec.Code)
}
}