Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 901692ead7 | |||
| 39b39740a3 | |||
| 63701dd086 | |||
| ba8808b6ab | |||
| a0c937b876 |
@@ -275,9 +275,23 @@ jobs:
|
||||
- name: Registry login
|
||||
run: echo "$REGISTRY_LOGIN_PASSWORD" | docker login "${{ steps.reg.outputs.host }}" -u "${{ steps.reg.outputs.user }}" --password-stdin
|
||||
|
||||
# A buildx builder is needed to emit a multi-architecture manifest. The
|
||||
# Dockerfile cross-compiles (the Go build is CGO-free and honours
|
||||
# TARGETARCH), so no QEMU emulation is involved and the arm64 image costs
|
||||
# little more than the amd64 one.
|
||||
- name: Set up buildx
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx create --name orchestrad --use --driver docker-container 2>/dev/null \
|
||||
|| docker buildx use orchestrad
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
# One multi-stage build compiles the Next.js UI, embeds it, and produces
|
||||
# the Go binary. Tag both the immutable version and latest (main only).
|
||||
- name: Build image
|
||||
# the Go binary for each target architecture. Tag both the immutable
|
||||
# version and latest (main only). Built and pushed in a single step:
|
||||
# a multi-platform result cannot be loaded into the local daemon, so it
|
||||
# goes straight to the registry as a manifest list.
|
||||
- name: Build and push image
|
||||
env:
|
||||
IMAGE: ${{ steps.reg.outputs.image }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
@@ -285,23 +299,25 @@ jobs:
|
||||
BUILD_TIME: ${{ steps.ver.outputs.build_time }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build \
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg VERSION="$VERSION" \
|
||||
--build-arg GIT_COMMIT="$GIT_COMMIT" \
|
||||
--build-arg BUILD_TIME="$BUILD_TIME" \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
--push \
|
||||
.
|
||||
echo "Published ${IMAGE}:${VERSION} and ${IMAGE}:latest (linux/amd64, linux/arm64)"
|
||||
|
||||
- name: Push image
|
||||
- name: Verify image architectures
|
||||
env:
|
||||
IMAGE: ${{ steps.reg.outputs.image }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker push "${IMAGE}:${VERSION}"
|
||||
docker push "${IMAGE}:latest"
|
||||
echo "Published ${IMAGE}:${VERSION} and ${IMAGE}:latest"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}" \
|
||||
| grep -E 'Platform|Name' || true
|
||||
|
||||
# Create the Gitea release and attach every binary artifact in dist/. The
|
||||
# MSI is added later by the msi job.
|
||||
|
||||
+12
-4
@@ -7,7 +7,7 @@
|
||||
# Stage 1: build the frontend (Next.js static export -> frontend/out)
|
||||
# Debian base avoids musl/sharp native-module friction; this stage is discarded.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-bookworm-slim AS frontend
|
||||
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS frontend
|
||||
WORKDIR /frontend
|
||||
|
||||
# Install deps first for better layer caching. .npmrc carries
|
||||
@@ -23,9 +23,13 @@ RUN npm run build
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: build the Go binary with the UI embedded
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM golang:1.25-alpine AS builder
|
||||
# Pinned to the *build* platform and cross-compiled with GOARCH below, so an
|
||||
# arm64 image is produced natively on an amd64 runner with no QEMU emulation
|
||||
# (which would make the Go build minutes-long instead of seconds).
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
|
||||
# Pure-Go SQLite (modernc.org/sqlite) means no C toolchain is needed.
|
||||
# Pure-Go SQLite (modernc.org/sqlite) means no C toolchain is needed, which is
|
||||
# also what makes cross-compilation this simple.
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /build
|
||||
@@ -45,7 +49,11 @@ ARG VERSION=dev
|
||||
ARG BUILD_TIME=unknown
|
||||
ARG GIT_COMMIT=unknown
|
||||
|
||||
RUN CGO_ENABLED=0 go build \
|
||||
# TARGETOS/TARGETARCH are supplied automatically by buildx for each --platform.
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build \
|
||||
-ldflags "-s -w \
|
||||
-X github.com/Grace-Solutions/OrchestrAD/internal/version.Version=${VERSION} \
|
||||
-X github.com/Grace-Solutions/OrchestrAD/internal/version.BuildTime=${BUILD_TIME} \
|
||||
|
||||
@@ -136,7 +136,30 @@ func (h *BackupsHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
emitAudit(h.auditService, r, audit.EventRestore, "Backup", "", "Restore", true,
|
||||
map[string]any{"filePath": filePath}, "")
|
||||
WriteJSON(w, http.StatusOK, map[string]bool{"restored": true})
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"staged": true,
|
||||
"restartRequired": true,
|
||||
"message": "Restore staged and validated. It is applied the next time OrchestrAD starts; " +
|
||||
"the database being replaced is preserved alongside it.",
|
||||
})
|
||||
}
|
||||
|
||||
// CancelRestore handles DELETE /api/v1/backups/restore — discards a staged
|
||||
// restore that has not been applied yet.
|
||||
func (h *BackupsHandler) CancelRestore(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.service.CancelRestore(); err != nil {
|
||||
h.logger.Error("BackupsHandler", "CancelRestore failed: %v", err)
|
||||
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to cancel the staged restore")
|
||||
return
|
||||
}
|
||||
emitAudit(h.auditService, r, audit.EventRestore, "Backup", "", "CancelRestore", true, nil, "")
|
||||
WriteJSON(w, http.StatusOK, map[string]bool{"cancelled": true})
|
||||
}
|
||||
|
||||
// RestoreStatus handles GET /api/v1/backups/restore — reports whether a restore
|
||||
// is staged and waiting for a restart.
|
||||
func (h *BackupsHandler) RestoreStatus(w http.ResponseWriter, r *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]bool{"pending": h.service.PendingRestore()})
|
||||
}
|
||||
|
||||
func backupToResponse(b *services.BackupInfo) BackupResponse {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package api - CSRF protection.
|
||||
//
|
||||
// CSRF only matters for credentials the browser attaches automatically. The SPA
|
||||
// authenticates with a bearer token it holds in localStorage and sets on each
|
||||
// request, and API clients send X-API-Key — neither is ambient, so neither is
|
||||
// forgeable cross-site, and both skip these checks. What the middleware guards
|
||||
// is cookie-authenticated mutation.
|
||||
//
|
||||
// Tokens are stateless and signed: <nonce>.<base64 HMAC-SHA256(nonce)> keyed by
|
||||
// the application secret. That means no server-side store to expire or
|
||||
// replicate, while a token still cannot be minted by an attacker who cannot
|
||||
// read the secret.
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CSRF issues and validates CSRF tokens.
|
||||
type CSRF struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewCSRF creates a CSRF issuer/validator keyed by the application secret.
|
||||
func NewCSRF(secret []byte) *CSRF {
|
||||
return &CSRF{secret: secret}
|
||||
}
|
||||
|
||||
// IssueToken returns a fresh signed token.
|
||||
func (c *CSRF) IssueToken() (string, error) {
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("generating csrf nonce: %w", err)
|
||||
}
|
||||
n := base64.RawURLEncoding.EncodeToString(nonce)
|
||||
return n + "." + c.sign(n), nil
|
||||
}
|
||||
|
||||
// ValidToken reports whether token was issued by this server.
|
||||
func (c *CSRF) ValidToken(token string) bool {
|
||||
nonce, sig, ok := strings.Cut(token, ".")
|
||||
if !ok || nonce == "" || sig == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(sig), []byte(c.sign(nonce))) == 1
|
||||
}
|
||||
|
||||
func (c *CSRF) sign(nonce string) string {
|
||||
mac := hmac.New(sha256.New, c.secret)
|
||||
mac.Write([]byte(nonce))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// Handler serves GET /api/v1/auth/csrf.
|
||||
func (c *CSRF) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := c.IssueToken()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Could not issue a CSRF token")
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"token": token})
|
||||
}
|
||||
|
||||
// Middleware rejects cookie-authenticated mutating requests that do not carry a
|
||||
// valid X-CSRF-Token. Safe methods pass, and so do requests that authenticate
|
||||
// with an explicit Authorization/X-API-Key header, which CSRF cannot forge.
|
||||
func (c *CSRF) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isReadMethod(r.Method) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Explicit credentials are not attached by the browser on a cross-site
|
||||
// request, so these are not CSRF-reachable.
|
||||
if r.Header.Get("X-API-Key") != "" || strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// No ambient credential either: nothing to protect. Let it through so
|
||||
// the auth middleware produces the 401.
|
||||
if _, err := r.Cookie(DocsCookieName); err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !c.ValidToken(r.Header.Get("X-CSRF-Token")) {
|
||||
WriteError(w, http.StatusForbidden, ErrCodeForbidden,
|
||||
"A valid X-CSRF-Token header is required for cookie-authenticated requests (get one from GET /api/v1/auth/csrf)")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCSRFTokenIssueAndValidate(t *testing.T) {
|
||||
c := NewCSRF([]byte("a-test-secret"))
|
||||
token, err := c.IssueToken()
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
if !c.ValidToken(token) {
|
||||
t.Error("a freshly issued token should validate")
|
||||
}
|
||||
|
||||
// Tokens are unguessable and signed: tampering, forging, and tokens from a
|
||||
// different secret must all fail.
|
||||
for name, bad := range map[string]string{
|
||||
"empty": "",
|
||||
"no signature": "justanonce",
|
||||
"bad sig": "nonce.not-a-real-signature",
|
||||
"tampered": "x" + token,
|
||||
} {
|
||||
if c.ValidToken(bad) {
|
||||
t.Errorf("%s token should not validate", name)
|
||||
}
|
||||
}
|
||||
if NewCSRF([]byte("a-different-secret")).ValidToken(token) {
|
||||
t.Error("a token must not validate under a different secret")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSRFMiddleware pins who is challenged and who is not: cookie-authenticated
|
||||
// mutations need a valid token; bearer/API-key requests and safe methods do not.
|
||||
func TestCSRFMiddleware(t *testing.T) {
|
||||
c := NewCSRF([]byte("a-test-secret"))
|
||||
token, _ := c.IssueToken()
|
||||
reached := false
|
||||
h := c.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
}))
|
||||
|
||||
call := func(method string, setup func(*http.Request)) int {
|
||||
reached = false
|
||||
req := httptest.NewRequest(method, "/api/v1/rules", nil)
|
||||
if setup != nil {
|
||||
setup(req)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
withCookie := func(r *http.Request) {
|
||||
r.AddCookie(&http.Cookie{Name: DocsCookieName, Value: "sometoken"})
|
||||
}
|
||||
|
||||
// Safe methods are never challenged.
|
||||
if code := call(http.MethodGet, withCookie); code != http.StatusOK || !reached {
|
||||
t.Errorf("GET with cookie: code=%d reached=%v, want pass-through", code, reached)
|
||||
}
|
||||
|
||||
// Cookie-authenticated mutation without a token is rejected.
|
||||
if code := call(http.MethodPost, withCookie); code != http.StatusForbidden || reached {
|
||||
t.Errorf("POST with cookie and no CSRF token: code=%d reached=%v, want 403", code, reached)
|
||||
}
|
||||
|
||||
// ... and accepted with a valid one.
|
||||
if code := call(http.MethodPost, func(r *http.Request) {
|
||||
withCookie(r)
|
||||
r.Header.Set("X-CSRF-Token", token)
|
||||
}); code != http.StatusOK || !reached {
|
||||
t.Errorf("POST with a valid CSRF token: code=%d reached=%v, want pass-through", code, reached)
|
||||
}
|
||||
|
||||
// An invalid token is rejected even though the header is present — this is
|
||||
// exactly what the previous placeholder implementation let through.
|
||||
if code := call(http.MethodPost, func(r *http.Request) {
|
||||
withCookie(r)
|
||||
r.Header.Set("X-CSRF-Token", "anything-at-all")
|
||||
}); code != http.StatusForbidden || reached {
|
||||
t.Errorf("POST with a bogus CSRF token: code=%d reached=%v, want 403", code, reached)
|
||||
}
|
||||
|
||||
// Explicit credentials are not CSRF-reachable, so they pass untouched.
|
||||
if code := call(http.MethodPost, func(r *http.Request) {
|
||||
r.Header.Set("Authorization", "Bearer sometoken")
|
||||
}); code != http.StatusOK || !reached {
|
||||
t.Errorf("bearer POST: code=%d reached=%v, want pass-through", code, reached)
|
||||
}
|
||||
if code := call(http.MethodPost, func(r *http.Request) {
|
||||
r.Header.Set("X-API-Key", "somekey")
|
||||
}); code != http.StatusOK || !reached {
|
||||
t.Errorf("api-key POST: code=%d reached=%v, want pass-through", code, reached)
|
||||
}
|
||||
|
||||
// No ambient credential at all: let it through so auth returns 401.
|
||||
if code := call(http.MethodPost, nil); code != http.StatusOK || !reached {
|
||||
t.Errorf("anonymous POST: code=%d reached=%v, want pass-through to auth", code, reached)
|
||||
}
|
||||
}
|
||||
@@ -188,35 +188,5 @@ func hasAnyRole(user *models.User, roles []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CSRFMiddleware validates CSRF tokens for mutation requests
|
||||
func CSRFMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip CSRF for GET, HEAD, OPTIONS
|
||||
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip CSRF for API key auth
|
||||
if r.Header.Get("X-API-Key") != "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip CSRF for Bearer token auth (typically from NextAuth)
|
||||
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Validate CSRF token from header/body
|
||||
csrfToken := r.Header.Get("X-CSRF-Token")
|
||||
if csrfToken == "" {
|
||||
WriteError(w, http.StatusForbidden, ErrCodeForbidden, "CSRF token required")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Validate the token against stored session
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
// CSRF protection lives in csrf.go: see CSRF.Middleware, which validates
|
||||
// signed tokens instead of merely checking that a header is present.
|
||||
|
||||
@@ -262,8 +262,8 @@ func BuildOpenAPISpec(router chi.Router) map[string]any {
|
||||
return map[string]any{
|
||||
"openapi": "3.0.3",
|
||||
"info": map[string]any{
|
||||
"title": "OrchestrAD API",
|
||||
"version": "1",
|
||||
"title": "OrchestrAD API",
|
||||
"version": "1",
|
||||
"description": "Active Directory rule automation. Authenticate at /api/v1/auth/login, then send the returned token as `Authorization: Bearer <token>` (or use an API key in `X-API-Key`). " +
|
||||
"This document and GET /api/routes accept `?method=get,post` and `?path=<substring>` filters to narrow the listed operations.",
|
||||
},
|
||||
|
||||
@@ -28,8 +28,8 @@ func NewTLSHandler(settings *services.SettingsService, manager *tlsmgr.Manager,
|
||||
}
|
||||
|
||||
type tlsStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
WindowsSupported bool `json:"windowsStoreSupported"`
|
||||
Enabled bool `json:"enabled"`
|
||||
WindowsSupported bool `json:"windowsStoreSupported"`
|
||||
Certificate *tlsmgr.Info `json:"certificate,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,21 @@ func runServer(ctx context.Context) error {
|
||||
return fmt.Errorf("initializing encryptor: %w", err)
|
||||
}
|
||||
|
||||
// Say where the key came from, so an operator can see at a glance whether
|
||||
// the install is on a persisted key (portable) or a supplied one.
|
||||
switch cfg.SecretKeySource {
|
||||
case config.SecretKeySourceGenerated:
|
||||
logger.Info("SecretKey", "Generated a new secret key and stored it at %s - back this file up; "+
|
||||
"stored credentials cannot be decrypted without it",
|
||||
filepath.Join(cfg.DataPath, config.SecretKeyFileName))
|
||||
case config.SecretKeySourceLegacy:
|
||||
logger.Warn("SecretKey", "Adopted the legacy built-in secret key for this existing database and wrote it to %s. "+
|
||||
"It is not secret: rotate it by setting ORCHESTRAD_SECRET_KEY (or replacing that file) and re-entering credential passwords",
|
||||
filepath.Join(cfg.DataPath, config.SecretKeyFileName))
|
||||
default:
|
||||
logger.Info("SecretKey", "Using the secret key from the %s", cfg.SecretKeySource)
|
||||
}
|
||||
|
||||
// Confirm the stored credential secrets decrypt with this key. A changed
|
||||
// ORCHESTRAD_SECRET_KEY otherwise only shows up later, as an opaque
|
||||
// "decryption failed" inside whatever operation first needs a bind.
|
||||
@@ -152,7 +167,7 @@ func runServer(ctx context.Context) error {
|
||||
RunRepo: repository.NewRuleRunRepository(database.Conn()),
|
||||
UserRepo: repository.NewUserRepository(database.Conn()),
|
||||
APIKeyService: services.NewAPIKeyService(database.Conn(), logger),
|
||||
BackupService: services.NewBackupService(database, filepath.Join(cfg.DataPath, "backups"), 3, logger),
|
||||
BackupService: services.NewBackupService(database, backupDir(cfg), maxBackups, logger),
|
||||
SettingsService: settingsService,
|
||||
DashboardService: services.NewDashboardService(database.Conn(), logger),
|
||||
ActivityService: services.NewActivityService(database.Conn(), logger),
|
||||
@@ -248,14 +263,66 @@ func RunMigrate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunBackup creates a manual database backup
|
||||
func RunBackup() error {
|
||||
return fmt.Errorf("backup not yet implemented")
|
||||
// maxBackups is how many backup files are retained (oldest pruned beyond it).
|
||||
const maxBackups = 3
|
||||
|
||||
// backupDir is where backup files live inside the data directory.
|
||||
func backupDir(cfg *config.Config) string {
|
||||
return filepath.Join(cfg.DataPath, "backups")
|
||||
}
|
||||
|
||||
// RunRestore restores the database from a backup file
|
||||
func RunRestore(filepath string) error {
|
||||
return fmt.Errorf("restore not yet implemented: %s", filepath)
|
||||
// RunBackup creates a manual database backup
|
||||
func RunBackup() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger := logging.Init(cfg.Logging)
|
||||
database, err := db.New(cfg.Database, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
svc := services.NewBackupService(database, backupDir(cfg), maxBackups, logger)
|
||||
backup, err := svc.CreateBackup("manual", "cli")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Backup", "Created %s (%d bytes)", backup.FilePath, backup.SizeBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunRestore restores the database from a backup file. Unlike the API — which
|
||||
// stages a restore for the next start because the server holds an open
|
||||
// connection pool — the CLI runs standalone, so it stages and applies in one
|
||||
// step by opening the database immediately afterwards.
|
||||
func RunRestore(path string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger := logging.Init(cfg.Logging)
|
||||
|
||||
if err := db.ValidateBackup(path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.StageRestore(cfg.Database.Path, path); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Restore", "Backup validated and staged from %s", path)
|
||||
|
||||
// db.New applies the staged file before opening the pool.
|
||||
database, err := db.New(cfg.Database, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening the restored database: %w", err)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.Migrate(); err != nil {
|
||||
return fmt.Errorf("migrating the restored database: %w", err)
|
||||
}
|
||||
logger.Info("Restore", "Restore complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunDoctor validates system configuration and health
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/config"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/pki"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/tlsmgr"
|
||||
"github.com/kardianos/service"
|
||||
)
|
||||
|
||||
@@ -199,6 +201,31 @@ func removeService() error {
|
||||
return fmt.Errorf("removing service: %w", err)
|
||||
}
|
||||
_ = RemoveFirewallRule()
|
||||
removeStoreCertificate()
|
||||
logging.Info("Service", "Service removed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeStoreCertificate deletes the self-managed leaf we published into the
|
||||
// host's personal certificate store, so uninstalling does not leave an orphan
|
||||
// behind. Best-effort: the certificate may never have been installed, and the
|
||||
// data directory may already be gone.
|
||||
func removeStoreCertificate() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
certPEM, err := os.ReadFile(filepath.Join(cfg.DataPath, "tls", "server.crt"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cert, err := pki.ParseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := tlsmgr.RemoveLeafFromMyStore(cert); err != nil {
|
||||
logging.Warn("Service", "Could not remove the server certificate from the host store: %v", err)
|
||||
return
|
||||
}
|
||||
logging.Info("Service", "Removed the server certificate from the host store")
|
||||
}
|
||||
|
||||
@@ -13,12 +13,15 @@ import (
|
||||
type Config struct {
|
||||
DataPath string
|
||||
SecretKey []byte
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Logging LoggingConfig
|
||||
OIDC OIDCConfig
|
||||
CORS CORSConfig
|
||||
Maintenance MaintenanceConfig
|
||||
// SecretKeySource records where SecretKey came from, so startup can report
|
||||
// it (and warn when the legacy built-in default was adopted).
|
||||
SecretKeySource string
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Logging LoggingConfig
|
||||
OIDC OIDCConfig
|
||||
CORS CORSConfig
|
||||
Maintenance MaintenanceConfig
|
||||
}
|
||||
|
||||
// MaintenanceConfig controls background history pruning and database compaction
|
||||
@@ -82,24 +85,27 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("creating data directory: %w", err)
|
||||
}
|
||||
|
||||
secretKey, err := getEnvOrFile("ORCHESTRAD_SECRET_KEY", "ORCHESTRAD_SECRET_KEY_FILE")
|
||||
dbPath := filepath.Join(dataPath, "db", "orchestrad.db")
|
||||
|
||||
// The master key for credential encryption. When nothing supplies one it is
|
||||
// generated and persisted inside the data directory, so restarts keep the
|
||||
// same key and moving the stack to another server is a matter of copying
|
||||
// the data directory.
|
||||
secretKey, secretKeySource, err := resolveSecretKey(dataPath, dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading secret key: %w", err)
|
||||
}
|
||||
if len(secretKey) == 0 {
|
||||
// Generate a warning but allow startup for development
|
||||
secretKey = []byte("INSECURE-DEV-KEY-CHANGE-IN-PRODUCTION!")
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
DataPath: dataPath,
|
||||
SecretKey: secretKey,
|
||||
DataPath: dataPath,
|
||||
SecretKey: secretKey,
|
||||
SecretKeySource: secretKeySource,
|
||||
Server: ServerConfig{
|
||||
// Listen address/port precedence: environment variable, then the
|
||||
// value the Windows installer recorded in the registry, then the
|
||||
// built-in default. (On non-Windows registrySetting returns "".)
|
||||
Host: getEnv("ORCHESTRAD_HOST", firstNonEmpty(registrySetting("ListenAddress"), "0.0.0.0")),
|
||||
Port: getEnvInt("ORCHESTRAD_PORT", atoiOr(registrySetting("ListenPort"), 18090)),
|
||||
Host: getEnv("ORCHESTRAD_HOST", firstNonEmpty(registrySetting("ListenAddress"), "0.0.0.0")),
|
||||
Port: getEnvInt("ORCHESTRAD_PORT", atoiOr(registrySetting("ListenPort"), 18090)),
|
||||
// Trust reverse proxies in local/private ranges by default so
|
||||
// X-Forwarded-* headers (client IP, scheme, host) are honored out
|
||||
// of the box behind an edge proxy. Override with an explicit CIDR
|
||||
@@ -107,7 +113,7 @@ func Load() (*Config, error) {
|
||||
TrustedProxies: splitCSV(getEnv("ORCHESTRAD_TRUSTED_PROXIES", "local")),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Path: filepath.Join(dataPath, "db", "orchestrad.db"),
|
||||
Path: dbPath,
|
||||
MaxOpenConns: getEnvInt("ORCHESTRAD_DB_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: getEnvInt("ORCHESTRAD_DB_MAX_IDLE_CONNS", 5),
|
||||
WALMode: true,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// SecretKeyFileName is the file inside the data directory where a generated
|
||||
// secret key is persisted, so the key travels with the data directory and the
|
||||
// whole stack can be moved to another server by copying it.
|
||||
const SecretKeyFileName = "secret.key"
|
||||
|
||||
// legacyDefaultSecretKey was used by builds that had neither
|
||||
// ORCHESTRAD_SECRET_KEY nor a key file. Installs that ran on it encrypted their
|
||||
// credentials with it, so it must be adopted rather than replaced when we find
|
||||
// an existing database — swapping in a fresh key would render those secrets
|
||||
// undecryptable.
|
||||
const legacyDefaultSecretKey = "INSECURE-DEV-KEY-CHANGE-IN-PRODUCTION!"
|
||||
|
||||
// Secret key provenance, reported so startup can log where the key came from.
|
||||
const (
|
||||
SecretKeySourceEnv = "environment"
|
||||
SecretKeySourceEnvFile = "key file (ORCHESTRAD_SECRET_KEY_FILE)"
|
||||
SecretKeySourceDataFile = "data directory"
|
||||
SecretKeySourceGenerated = "generated"
|
||||
SecretKeySourceLegacy = "legacy built-in default"
|
||||
)
|
||||
|
||||
// resolveSecretKey determines the encryption master key and, when nothing
|
||||
// supplied one, persists a key inside dataPath so it survives restarts and
|
||||
// moves with a copied data directory.
|
||||
//
|
||||
// Precedence:
|
||||
// 1. ORCHESTRAD_SECRET_KEY / ORCHESTRAD_SECRET_KEY_FILE (unchanged behaviour)
|
||||
// 2. <data>/secret.key, if present
|
||||
// 3. a newly generated 32-byte random key, written to <data>/secret.key
|
||||
//
|
||||
// Case 3 has one important exception. If a database already exists, this is an
|
||||
// upgrade of an install that was silently running on the legacy built-in
|
||||
// default, and its stored credentials are encrypted with it. Generating a new
|
||||
// key there would break every credential, so the legacy value is adopted and
|
||||
// written to the key file instead; the caller is expected to warn that it
|
||||
// should be rotated.
|
||||
func resolveSecretKey(dataPath, dbPath string) (key []byte, source string, err error) {
|
||||
if v := os.Getenv("ORCHESTRAD_SECRET_KEY"); v != "" {
|
||||
return []byte(v), SecretKeySourceEnv, nil
|
||||
}
|
||||
if fromFile, ferr := getEnvOrFile("ORCHESTRAD_SECRET_KEY", "ORCHESTRAD_SECRET_KEY_FILE"); ferr != nil {
|
||||
return nil, "", ferr
|
||||
} else if len(fromFile) > 0 {
|
||||
return fromFile, SecretKeySourceEnvFile, nil
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(dataPath, SecretKeyFileName)
|
||||
if data, rerr := os.ReadFile(keyPath); rerr == nil {
|
||||
if trimmed := trimKey(data); len(trimmed) > 0 {
|
||||
return trimmed, SecretKeySourceDataFile, nil
|
||||
}
|
||||
}
|
||||
|
||||
// No key anywhere. Adopt the legacy default if this install already has a
|
||||
// database (its secrets are encrypted with it); otherwise mint a fresh one.
|
||||
generated := true
|
||||
if _, serr := os.Stat(dbPath); serr == nil {
|
||||
key = []byte(legacyDefaultSecretKey)
|
||||
source = SecretKeySourceLegacy
|
||||
generated = false
|
||||
} else {
|
||||
raw := make([]byte, 32)
|
||||
if _, rerr := rand.Read(raw); rerr != nil {
|
||||
return nil, "", fmt.Errorf("generating secret key: %w", rerr)
|
||||
}
|
||||
key = []byte(base64.RawStdEncoding.EncodeToString(raw))
|
||||
source = SecretKeySourceGenerated
|
||||
}
|
||||
|
||||
if werr := writeSecretKeyFile(keyPath, key); werr != nil {
|
||||
// Not fatal: the process can run with the key in memory. But a generated
|
||||
// key that cannot be persisted would differ on the next start and lose
|
||||
// every stored secret, so that case must fail loudly.
|
||||
if generated {
|
||||
return nil, "", fmt.Errorf("persisting generated secret key to %s: %w", keyPath, werr)
|
||||
}
|
||||
}
|
||||
return key, source, nil
|
||||
}
|
||||
|
||||
// writeSecretKeyFile writes the key with owner-only permissions.
|
||||
func writeSecretKeyFile(path string, key []byte) error {
|
||||
if err := os.WriteFile(path, append(key, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return restrictSecretKeyFile(path)
|
||||
}
|
||||
|
||||
func trimKey(data []byte) []byte {
|
||||
s := string(data)
|
||||
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
// restrictSecretKeyFile enforces owner-only permissions. os.WriteFile already
|
||||
// created the file with 0600, but an existing file keeps its original mode, so
|
||||
// chmod is applied explicitly.
|
||||
func restrictSecretKeyFile(path string) error {
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSecretKeyPersistedAndStable is the property that makes the stack
|
||||
// portable: with nothing configured, a key is generated once, written into the
|
||||
// data directory, and reused verbatim on every later start.
|
||||
func TestSecretKeyPersistedAndStable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "db", "orchestrad.db")
|
||||
t.Setenv("ORCHESTRAD_SECRET_KEY", "")
|
||||
t.Setenv("ORCHESTRAD_SECRET_KEY_FILE", "")
|
||||
|
||||
first, source, err := resolveSecretKey(dir, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSecretKey: %v", err)
|
||||
}
|
||||
if source != SecretKeySourceGenerated {
|
||||
t.Errorf("source = %q, want %q", source, SecretKeySourceGenerated)
|
||||
}
|
||||
if len(first) < 32 {
|
||||
t.Errorf("generated key too short (%d bytes)", len(first))
|
||||
}
|
||||
keyFile := filepath.Join(dir, SecretKeyFileName)
|
||||
if _, err := os.Stat(keyFile); err != nil {
|
||||
t.Fatalf("key file not written: %v", err)
|
||||
}
|
||||
|
||||
second, source, err := resolveSecretKey(dir, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSecretKey (second): %v", err)
|
||||
}
|
||||
if source != SecretKeySourceDataFile {
|
||||
t.Errorf("second source = %q, want %q", source, SecretKeySourceDataFile)
|
||||
}
|
||||
if string(second) != string(first) {
|
||||
t.Errorf("key changed between runs: %q -> %q", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretKeyAdoptsLegacyForExistingDatabase covers the upgrade path: an
|
||||
// install that was silently running on the built-in default must keep using it,
|
||||
// or every stored credential becomes undecryptable.
|
||||
func TestSecretKeyAdoptsLegacyForExistingDatabase(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "db", "orchestrad.db")
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dbPath, []byte("pretend database"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ORCHESTRAD_SECRET_KEY", "")
|
||||
t.Setenv("ORCHESTRAD_SECRET_KEY_FILE", "")
|
||||
|
||||
key, source, err := resolveSecretKey(dir, dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSecretKey: %v", err)
|
||||
}
|
||||
if source != SecretKeySourceLegacy {
|
||||
t.Errorf("source = %q, want %q", source, SecretKeySourceLegacy)
|
||||
}
|
||||
if string(key) != legacyDefaultSecretKey {
|
||||
t.Errorf("existing install must keep the legacy key, got %q", key)
|
||||
}
|
||||
// It is written out, so the next start reads it from the file rather than
|
||||
// re-deriving it from this special case.
|
||||
if data, rerr := os.ReadFile(filepath.Join(dir, SecretKeyFileName)); rerr != nil {
|
||||
t.Errorf("legacy key not persisted: %v", rerr)
|
||||
} else if string(trimKey(data)) != legacyDefaultSecretKey {
|
||||
t.Errorf("persisted key = %q", trimKey(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretKeyEnvWins: an explicitly supplied key takes precedence and is not
|
||||
// overwritten by a generated one.
|
||||
func TestSecretKeyEnvWins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCHESTRAD_SECRET_KEY", "explicit-key-from-the-environment")
|
||||
|
||||
key, source, err := resolveSecretKey(dir, filepath.Join(dir, "db", "orchestrad.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSecretKey: %v", err)
|
||||
}
|
||||
if source != SecretKeySourceEnv || string(key) != "explicit-key-from-the-environment" {
|
||||
t.Errorf("env key not honoured: source=%q key=%q", source, key)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, SecretKeyFileName)); err == nil {
|
||||
t.Error("must not write a key file when the key is supplied explicitly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretKeyFromKeyFileEnv covers ORCHESTRAD_SECRET_KEY_FILE, the documented
|
||||
// way to mount a key as a Docker/Kubernetes secret.
|
||||
func TestSecretKeyFromKeyFileEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
external := filepath.Join(dir, "mounted.key")
|
||||
if err := os.WriteFile(external, []byte("mounted-secret-value\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ORCHESTRAD_SECRET_KEY", "")
|
||||
t.Setenv("ORCHESTRAD_SECRET_KEY_FILE", external)
|
||||
|
||||
key, source, err := resolveSecretKey(dir, filepath.Join(dir, "db", "orchestrad.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSecretKey: %v", err)
|
||||
}
|
||||
if source != SecretKeySourceEnvFile {
|
||||
t.Errorf("source = %q, want %q", source, SecretKeySourceEnvFile)
|
||||
}
|
||||
if string(key) != "mounted-secret-value" {
|
||||
t.Errorf("key = %q, want the file contents with the newline stripped", key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// restrictSecretKeyFile tightens the ACL on the generated key file. On Windows
|
||||
// the mode passed to os.WriteFile is effectively ignored — the file inherits
|
||||
// the directory's ACL — so the inherited entries are dropped and access is
|
||||
// granted only to SYSTEM, Administrators, and the account that created it (the
|
||||
// service typically runs as LocalSystem).
|
||||
//
|
||||
// Best-effort: the key is already written, and a data directory that is itself
|
||||
// protected is the common case. Failure to tighten is not fatal.
|
||||
func restrictSecretKeyFile(path string) error {
|
||||
args := []string{path, "/inheritance:r",
|
||||
"/grant:r", "*S-1-5-18:F", // SYSTEM (SID, so it works on localized Windows)
|
||||
"/grant:r", "*S-1-5-32-544:F", // BUILTIN\Administrators
|
||||
}
|
||||
if u := os.Getenv("USERNAME"); u != "" {
|
||||
args = append(args, "/grant:r", u+":F")
|
||||
}
|
||||
cmd := exec.Command("icacls", args...)
|
||||
cmd.Stdout, cmd.Stderr = nil, nil
|
||||
_ = cmd.Run()
|
||||
return nil
|
||||
}
|
||||
@@ -38,6 +38,10 @@ func New(cfg config.DatabaseConfig, logger *logging.Logger) (*DB, error) {
|
||||
migrateLegacyDBLocation(cfg.Path, logger)
|
||||
}
|
||||
|
||||
// A restore staged by the API is applied here, before the pool is opened —
|
||||
// the only point at which swapping the file is safe.
|
||||
applyPendingRestore(cfg.Path, logger)
|
||||
|
||||
logger.Info("Database", "Opening database at %s", cfg.Path)
|
||||
|
||||
// Build connection string with pragmas. modernc.org/sqlite applies these
|
||||
@@ -98,6 +102,11 @@ func (db *DB) Conn() *sql.DB {
|
||||
return db.conn
|
||||
}
|
||||
|
||||
// Path returns the database file path.
|
||||
func (db *DB) Path() string {
|
||||
return db.config.Path
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (db *DB) Close() error {
|
||||
db.logger.Info("Database", "Closing database connection")
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Database restore.
|
||||
//
|
||||
// A running OrchestrAD holds an open connection pool that every repository
|
||||
// shares, and closing it invalidates those handles process-wide — so the
|
||||
// database file cannot be swapped underneath a live server. Restore is
|
||||
// therefore a two-phase operation:
|
||||
//
|
||||
// StageRestore validates the backup and parks it next to the database
|
||||
// ApplyPending runs before the pool is opened (from New) and moves it in
|
||||
//
|
||||
// The staged file is only ever applied by a process that has not yet opened the
|
||||
// database, which makes the swap safe without any coordination. The CLI
|
||||
// `restore` command performs both phases in one go, since it runs standalone.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
||||
)
|
||||
|
||||
// pendingSuffix marks a validated backup staged for the next start.
|
||||
const pendingSuffix = ".restore-pending"
|
||||
|
||||
// StageRestore validates backupPath and stages it to be applied to dbPath on
|
||||
// the next start. It does not modify the live database.
|
||||
func StageRestore(dbPath, backupPath string) error {
|
||||
if err := ValidateBackup(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
pending := dbPath + pendingSuffix
|
||||
if err := copyFile(backupPath, pending); err != nil {
|
||||
return fmt.Errorf("staging restore: %w", err)
|
||||
}
|
||||
// Validate the staged copy too, so a truncated copy can never be applied.
|
||||
if err := ValidateBackup(pending); err != nil {
|
||||
_ = os.Remove(pending)
|
||||
return fmt.Errorf("staged copy failed validation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PendingRestorePath returns the staged file for dbPath, or "" if none.
|
||||
func PendingRestorePath(dbPath string) string {
|
||||
pending := dbPath + pendingSuffix
|
||||
if _, err := os.Stat(pending); err != nil {
|
||||
return ""
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
// CancelPendingRestore discards a staged restore.
|
||||
func CancelPendingRestore(dbPath string) error {
|
||||
pending := dbPath + pendingSuffix
|
||||
if _, err := os.Stat(pending); err != nil {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(pending)
|
||||
}
|
||||
|
||||
// applyPendingRestore moves a staged restore into place. It must run before the
|
||||
// database is opened. The database being replaced is preserved alongside as
|
||||
// <db>.replaced-<timestamp> so a bad restore is recoverable, and the WAL/SHM
|
||||
// sidecars of the old database are removed — leaving them would let SQLite
|
||||
// replay a journal belonging to the previous file over the restored one.
|
||||
func applyPendingRestore(dbPath string, logger *logging.Logger) {
|
||||
pending := PendingRestorePath(dbPath)
|
||||
if pending == "" {
|
||||
return
|
||||
}
|
||||
logf := func(format string, args ...any) {
|
||||
if logger != nil {
|
||||
logger.Info("Database", format, args...)
|
||||
}
|
||||
}
|
||||
warnf := func(format string, args ...any) {
|
||||
if logger != nil {
|
||||
logger.Warn("Database", format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate: the staged file may have sat on disk across a crash.
|
||||
if err := ValidateBackup(pending); err != nil {
|
||||
warnf("Discarding staged restore, it did not validate: %v", err)
|
||||
_ = os.Remove(pending)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dbPath); err == nil {
|
||||
aside := fmt.Sprintf("%s.replaced-%s", dbPath, time.Now().UTC().Format("20060102-150405"))
|
||||
if err := os.Rename(dbPath, aside); err != nil {
|
||||
warnf("Could not set the current database aside (%v); restore not applied", err)
|
||||
return
|
||||
}
|
||||
logf("Previous database preserved at %s", filepath.Base(aside))
|
||||
}
|
||||
for _, suffix := range []string{"-wal", "-shm"} {
|
||||
_ = os.Remove(dbPath + suffix)
|
||||
}
|
||||
|
||||
if err := os.Rename(pending, dbPath); err != nil {
|
||||
warnf("Could not apply staged restore (%v)", err)
|
||||
return
|
||||
}
|
||||
logf("Applied staged restore from backup")
|
||||
}
|
||||
|
||||
// ValidateBackup checks that path is a readable SQLite database that passes an
|
||||
// integrity check and carries this application's schema. It is deliberately
|
||||
// strict: applying an unrelated or corrupt file would destroy the install.
|
||||
func ValidateBackup(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup file not found: %s", path)
|
||||
}
|
||||
if info.IsDir() || info.Size() == 0 {
|
||||
return fmt.Errorf("not a usable backup file: %s", path)
|
||||
}
|
||||
|
||||
// Open read-only so validation can never modify the candidate.
|
||||
conn, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=busy_timeout(5000)", path))
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening backup: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var result string
|
||||
if err := conn.QueryRow(`PRAGMA integrity_check`).Scan(&result); err != nil {
|
||||
return fmt.Errorf("backup is not a readable SQLite database: %w", err)
|
||||
}
|
||||
if result != "ok" {
|
||||
return fmt.Errorf("backup failed integrity check: %s", result)
|
||||
}
|
||||
|
||||
// It must be an OrchestrAD database, not just any SQLite file.
|
||||
var n int
|
||||
if err := conn.QueryRow(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'`,
|
||||
).Scan(&n); err != nil || n == 0 {
|
||||
return fmt.Errorf("backup does not look like an OrchestrAD database (no schema_migrations table)")
|
||||
}
|
||||
if err := conn.QueryRow(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`,
|
||||
).Scan(&n); err != nil || n == 0 {
|
||||
return fmt.Errorf("backup does not look like an OrchestrAD database (no users table)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
_ = os.Remove(dst)
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/config"
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T, path string) *DB {
|
||||
t.Helper()
|
||||
database, err := New(config.DatabaseConfig{
|
||||
Path: path, MaxOpenConns: 2, MaxIdleConns: 2, WALMode: true, ForeignKeys: true,
|
||||
}, logging.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("db.New: %v", err)
|
||||
}
|
||||
if err := database.Migrate(); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
// TestRestoreRoundTrip covers the full restore contract: a backup taken at one
|
||||
// point in time is staged, applied on the next open, and the data it held comes
|
||||
// back while the replaced database is preserved.
|
||||
func TestRestoreRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "db", "orchestrad.db")
|
||||
backupPath := filepath.Join(dir, "backup.db")
|
||||
|
||||
database := newTestDB(t, dbPath)
|
||||
if _, err := database.Conn().Exec(
|
||||
`INSERT INTO users (id, username, is_active, created_utc, updated_utc)
|
||||
VALUES ('u1','before-backup',1,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if err := database.Backup(backupPath); err != nil {
|
||||
t.Fatalf("Backup: %v", err)
|
||||
}
|
||||
|
||||
// Change the live database after the backup, so a successful restore is
|
||||
// observable: this row must be gone afterwards.
|
||||
if _, err := database.Conn().Exec(
|
||||
`INSERT INTO users (id, username, is_active, created_utc, updated_utc)
|
||||
VALUES ('u2','after-backup',1,'2026-01-02T00:00:00Z','2026-01-02T00:00:00Z')`); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
database.Close()
|
||||
|
||||
if err := StageRestore(dbPath, backupPath); err != nil {
|
||||
t.Fatalf("StageRestore: %v", err)
|
||||
}
|
||||
if PendingRestorePath(dbPath) == "" {
|
||||
t.Fatal("expected a pending restore after staging")
|
||||
}
|
||||
|
||||
// Reopening applies it.
|
||||
restored := newTestDB(t, dbPath)
|
||||
defer restored.Close()
|
||||
|
||||
if PendingRestorePath(dbPath) != "" {
|
||||
t.Error("pending restore should be consumed once applied")
|
||||
}
|
||||
var names []string
|
||||
rows, err := restored.Conn().Query(`SELECT username FROM users ORDER BY username`)
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
joined := strings.Join(names, ",")
|
||||
if !strings.Contains(joined, "before-backup") {
|
||||
t.Errorf("restored database missing the backed-up row; got %q", joined)
|
||||
}
|
||||
if strings.Contains(joined, "after-backup") {
|
||||
t.Errorf("restored database still has the post-backup row; restore did not apply (got %q)", joined)
|
||||
}
|
||||
|
||||
// The replaced database is kept, so a mistaken restore is recoverable.
|
||||
entries, _ := os.ReadDir(filepath.Dir(dbPath))
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if strings.Contains(e.Name(), ".replaced-") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected the replaced database to be preserved alongside")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateBackupRejectsJunk guards the check that stops an unrelated or
|
||||
// corrupt file from destroying an install.
|
||||
func TestValidateBackupRejectsJunk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
missing := filepath.Join(dir, "nope.db")
|
||||
if err := ValidateBackup(missing); err == nil {
|
||||
t.Error("missing file should not validate")
|
||||
}
|
||||
|
||||
empty := filepath.Join(dir, "empty.db")
|
||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateBackup(empty); err == nil {
|
||||
t.Error("empty file should not validate")
|
||||
}
|
||||
|
||||
garbage := filepath.Join(dir, "garbage.db")
|
||||
if err := os.WriteFile(garbage, []byte("this is definitely not sqlite"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateBackup(garbage); err == nil {
|
||||
t.Error("non-sqlite file should not validate")
|
||||
}
|
||||
|
||||
// A valid SQLite database that is not an OrchestrAD one must also be
|
||||
// refused — this is the case most likely to be an operator mistake.
|
||||
foreign := filepath.Join(dir, "foreign.db")
|
||||
conn, err := sql.Open("sqlite", foreign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.Exec(`CREATE TABLE something (id INTEGER)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
if err := ValidateBackup(foreign); err == nil {
|
||||
t.Error("a foreign SQLite database should not validate as an OrchestrAD backup")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelPendingRestore: a staged restore can be called off before restart.
|
||||
func TestCancelPendingRestore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "db", "orchestrad.db")
|
||||
backupPath := filepath.Join(dir, "backup.db")
|
||||
|
||||
database := newTestDB(t, dbPath)
|
||||
if err := database.Backup(backupPath); err != nil {
|
||||
t.Fatalf("Backup: %v", err)
|
||||
}
|
||||
database.Close()
|
||||
|
||||
if err := StageRestore(dbPath, backupPath); err != nil {
|
||||
t.Fatalf("StageRestore: %v", err)
|
||||
}
|
||||
if err := CancelPendingRestore(dbPath); err != nil {
|
||||
t.Fatalf("CancelPendingRestore: %v", err)
|
||||
}
|
||||
if PendingRestorePath(dbPath) != "" {
|
||||
t.Error("restore should no longer be pending after cancelling")
|
||||
}
|
||||
// Cancelling again is a no-op, not an error.
|
||||
if err := CancelPendingRestore(dbPath); err != nil {
|
||||
t.Errorf("second cancel should be a no-op: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,12 @@ func ObjectFilter(objectType types.ObjectType) string {
|
||||
|
||||
// Condition represents a filter condition
|
||||
type Condition struct {
|
||||
Attribute string
|
||||
Operator types.ConditionOperator
|
||||
Value string
|
||||
Negate bool
|
||||
CaseSensitive bool
|
||||
CustomLdap string
|
||||
Attribute string
|
||||
Operator types.ConditionOperator
|
||||
Value string
|
||||
Negate bool
|
||||
CaseSensitive bool
|
||||
CustomLdap string
|
||||
}
|
||||
|
||||
// BuildFilter builds an LDAP filter from a condition
|
||||
|
||||
@@ -64,6 +64,14 @@ func (c *Chain) Export(dir, pfxPassword string) error {
|
||||
if err := os.WriteFile(filepath.Join(dir, "server.pfx"), pfx, 0o600); err != nil {
|
||||
return fmt.Errorf("writing server.pfx: %w", err)
|
||||
}
|
||||
// Write the PFX password beside the bundle. The two live in the same
|
||||
// owner-only directory, so this grants no access that the .pfx itself
|
||||
// doesn't already give — and without it an operator importing the bundle
|
||||
// by hand has to go digging through settings for a password they never
|
||||
// chose.
|
||||
if err := os.WriteFile(filepath.Join(dir, "server.pfx.password"), []byte(pfxPassword+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("writing server.pfx.password: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -120,10 +120,10 @@ func TestBuildConditionGroups_FiltersDisabled(t *testing.T) {
|
||||
|
||||
func TestParentDN(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"CN=Alice,OU=Staff,DC=example,DC=com": "OU=Staff,DC=example,DC=com",
|
||||
"CN=Alice,OU=Staff,DC=example,DC=com": "OU=Staff,DC=example,DC=com",
|
||||
"CN=Alice, OU=Staff,DC=example,DC=com": "OU=Staff,DC=example,DC=com",
|
||||
"DC=com": "",
|
||||
"": "",
|
||||
"DC=com": "",
|
||||
"": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := parentDN(input); got != want {
|
||||
|
||||
@@ -49,7 +49,7 @@ type fakeStore struct {
|
||||
m map[string]map[string]bool // ruleID|groupDN -> set of member DNs
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore { return &fakeStore{m: map[string]map[string]bool{}} }
|
||||
func newFakeStore() *fakeStore { return &fakeStore{m: map[string]map[string]bool{}} }
|
||||
func (s *fakeStore) key(ruleID, groupDN string) string { return ruleID + "|" + groupDN }
|
||||
func (s *fakeStore) List(ruleID, groupDN string) ([]string, error) {
|
||||
var out []string
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
|
||||
// Context holds data for variable expansion
|
||||
type Context struct {
|
||||
Object map[string]string
|
||||
Rule map[string]string
|
||||
Now time.Time
|
||||
Custom map[string]string
|
||||
Object map[string]string
|
||||
Rule map[string]string
|
||||
Now time.Time
|
||||
Custom map[string]string
|
||||
}
|
||||
|
||||
// NewContext creates a new variable expansion context
|
||||
@@ -59,11 +59,11 @@ func NewExpander() *Expander {
|
||||
// Expand expands all variables in a template string
|
||||
func (e *Expander) Expand(template string, ctx *Context) (string, error) {
|
||||
var errors []string
|
||||
|
||||
|
||||
result := e.variablePattern.ReplaceAllStringFunc(template, func(match string) string {
|
||||
// Extract variable path (remove {{ and }})
|
||||
path := match[2 : len(match)-2]
|
||||
|
||||
|
||||
value, err := e.resolve(path, ctx)
|
||||
if err != nil {
|
||||
errors = append(errors, err.Error())
|
||||
@@ -71,11 +71,11 @@ func (e *Expander) Expand(template string, ctx *Context) (string, error) {
|
||||
}
|
||||
return value
|
||||
})
|
||||
|
||||
|
||||
if len(errors) > 0 {
|
||||
return result, fmt.Errorf("variable expansion errors: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -85,13 +85,13 @@ func (e *Expander) ExpandStrict(template string, ctx *Context) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
// Check for any remaining unexpanded variables
|
||||
remaining := e.variablePattern.FindAllString(result, -1)
|
||||
if len(remaining) > 0 {
|
||||
return "", fmt.Errorf("unresolved variables: %s", strings.Join(remaining, ", "))
|
||||
}
|
||||
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -100,32 +100,32 @@ func (e *Expander) resolve(path string, ctx *Context) (string, error) {
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid variable path: %s", path)
|
||||
}
|
||||
|
||||
|
||||
source := parts[0]
|
||||
property := parts[1]
|
||||
|
||||
|
||||
switch source {
|
||||
case "object":
|
||||
if val, ok := ctx.Object[property]; ok {
|
||||
return val, nil
|
||||
}
|
||||
return "", fmt.Errorf("object property not found: %s", property)
|
||||
|
||||
|
||||
case "rule":
|
||||
if val, ok := ctx.Rule[property]; ok {
|
||||
return val, nil
|
||||
}
|
||||
return "", fmt.Errorf("rule property not found: %s", property)
|
||||
|
||||
|
||||
case "now":
|
||||
return e.resolveNow(property, ctx.Now)
|
||||
|
||||
|
||||
case "custom":
|
||||
if val, ok := ctx.Custom[property]; ok {
|
||||
return val, nil
|
||||
}
|
||||
return "", fmt.Errorf("custom property not found: %s", property)
|
||||
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown variable source: %s", source)
|
||||
}
|
||||
@@ -159,28 +159,28 @@ func (e *Expander) ListVariables(template string) []string {
|
||||
matches := e.variablePattern.FindAllString(template, -1)
|
||||
unique := make(map[string]bool)
|
||||
var result []string
|
||||
|
||||
|
||||
for _, m := range matches {
|
||||
if !unique[m] {
|
||||
unique[m] = true
|
||||
result = append(result, m)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateTemplate checks if a template has valid variable syntax
|
||||
func (e *Expander) ValidateTemplate(template string) []string {
|
||||
var warnings []string
|
||||
|
||||
|
||||
// Check for unclosed braces
|
||||
openCount := strings.Count(template, "{{")
|
||||
closeCount := strings.Count(template, "}}")
|
||||
|
||||
|
||||
if openCount != closeCount {
|
||||
warnings = append(warnings, "mismatched variable delimiters")
|
||||
}
|
||||
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
@@ -136,8 +136,14 @@ func (s *Server) setupRoutes() {
|
||||
s.router.With(api.DocsPageAuthMiddleware(s.deps.AuthService)).Get("/api/docs", openAPIHandler.UI)
|
||||
s.router.With(api.AuthMiddleware(s.deps.AuthService)).Get("/api/docs/openapi.json", openAPIHandler.Spec)
|
||||
|
||||
// CSRF guards cookie-authenticated mutations. Bearer/API-key requests are
|
||||
// not CSRF-reachable and pass straight through, so this is transparent to
|
||||
// the SPA and to API clients.
|
||||
csrf := api.NewCSRF(s.config.SecretKey)
|
||||
|
||||
// API v1 routes
|
||||
s.router.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(csrf.Middleware)
|
||||
// Public system endpoints — discoverable without a session so that
|
||||
// liveness probes, reverse proxies, and the bootstrap flow can
|
||||
// interrogate the server before any user has signed in.
|
||||
@@ -154,7 +160,7 @@ func (s *Server) setupRoutes() {
|
||||
r.Post("/logout", authHandler.Logout)
|
||||
r.With(api.AuthMiddleware(s.deps.AuthService)).Get("/me", authHandler.Me)
|
||||
r.With(api.AuthMiddleware(s.deps.AuthService)).Post("/change-password", authHandler.ChangePassword)
|
||||
r.Get("/csrf", authHandler.CSRF)
|
||||
r.Get("/csrf", csrf.Handler)
|
||||
|
||||
// SSO (OIDC). status/login/callback are public; config is admin-only.
|
||||
r.Get("/oidc/status", oidcHandler.Status)
|
||||
@@ -255,6 +261,10 @@ func (s *Server) setupRoutes() {
|
||||
r.Get("/", backupsHandler.List)
|
||||
r.Post("/", backupsHandler.Create)
|
||||
r.Post("/{id}/restore", backupsHandler.Restore)
|
||||
// A restore is staged, then applied on the next start; these
|
||||
// let an operator see and cancel one before it takes effect.
|
||||
r.Get("/restore", backupsHandler.RestoreStatus)
|
||||
r.Delete("/restore", backupsHandler.CancelRestore)
|
||||
})
|
||||
|
||||
// API Keys — protected by the enclosing group's AuthMiddleware.
|
||||
|
||||
@@ -118,30 +118,41 @@ func (s *BackupService) ListBackups() ([]BackupInfo, error) {
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// RestoreBackup restores from a backup file
|
||||
// RestoreBackup validates a backup and stages it to be applied on the next
|
||||
// start. It deliberately does not swap the database underneath the running
|
||||
// server: every repository shares one connection pool, and closing it would
|
||||
// invalidate those handles process-wide. The staged file is applied by
|
||||
// db.New before the pool is opened, which is the only safe moment.
|
||||
//
|
||||
// A safety backup of the current database is taken first, so the state being
|
||||
// replaced is always recoverable.
|
||||
func (s *BackupService) RestoreBackup(backupPath string) error {
|
||||
s.logger.Info("BackupService", "Restoring from backup: %s", backupPath)
|
||||
s.logger.Info("BackupService", "Staging restore from backup: %s", backupPath)
|
||||
|
||||
// Verify backup file exists
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup file not found: %s", backupPath)
|
||||
if err := db.ValidateBackup(backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create safety backup before restore
|
||||
_, err := s.CreateBackup("pre-restore", "system")
|
||||
if err != nil {
|
||||
if _, err := s.CreateBackup("pre-restore", "system"); err != nil {
|
||||
s.logger.Warn("BackupService", "Failed to create safety backup: %v", err)
|
||||
}
|
||||
|
||||
// TODO: Implement actual restore logic
|
||||
// This would involve:
|
||||
// 1. Closing current database connection
|
||||
// 2. Copying backup file to database location
|
||||
// 3. Reopening database
|
||||
// 4. Running migrations if needed
|
||||
if err := db.StageRestore(s.database.Path(), backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("BackupService", "Restore completed from: %s", backupPath)
|
||||
return fmt.Errorf("restore not yet implemented")
|
||||
s.logger.Info("BackupService", "Restore staged from %s; it is applied on the next start", backupPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PendingRestore reports whether a restore is staged and awaiting a restart.
|
||||
func (s *BackupService) PendingRestore() bool {
|
||||
return db.PendingRestorePath(s.database.Path()) != ""
|
||||
}
|
||||
|
||||
// CancelRestore discards a staged restore.
|
||||
func (s *BackupService) CancelRestore() error {
|
||||
return db.CancelPendingRestore(s.database.Path())
|
||||
}
|
||||
|
||||
func (s *BackupService) recordBackup(backup *BackupInfo, triggeredBy string) error {
|
||||
|
||||
@@ -64,6 +64,13 @@ type Manager struct {
|
||||
renewBefore time.Duration
|
||||
logger *logging.Logger
|
||||
|
||||
// ensureMu serializes Ensure/Reload. Two callers can reach it at once — the
|
||||
// renewal loop and the TLS settings handler — and an overlapping run would
|
||||
// have them issuing leaves, rewriting <dir>, and mutating the host
|
||||
// certificate store underneath each other (the store prune removes any
|
||||
// superseded leaf, so a racing pair could delete the one just installed).
|
||||
ensureMu sync.Mutex
|
||||
|
||||
mu sync.RWMutex
|
||||
current *tls.Certificate
|
||||
mode string
|
||||
@@ -121,6 +128,9 @@ func (m *Manager) config() Config {
|
||||
// mode. On failure of a non-auto mode it falls back to the self-managed cert so
|
||||
// the server still comes up over HTTPS.
|
||||
func (m *Manager) Ensure() error {
|
||||
m.ensureMu.Lock()
|
||||
defer m.ensureMu.Unlock()
|
||||
|
||||
cfg := m.config()
|
||||
switch cfg.Mode {
|
||||
case ModeProvided:
|
||||
@@ -183,12 +193,17 @@ func (m *Manager) ensureAuto(pfxPassword string) error {
|
||||
leaf = existing
|
||||
}
|
||||
}
|
||||
// issued records whether this call actually minted a new leaf, rather than
|
||||
// reusing the persisted one, so the store-publish step can report a real
|
||||
// renewal instead of guessing from the certificate's age.
|
||||
issued := false
|
||||
if leaf == nil {
|
||||
m.logf("Issuing server certificate for CN=%s (SANs: %v)", m.cn, m.dnsNames)
|
||||
leaf, err = pki.IssueLeaf(inter, m.cn, m.dnsNames, m.ips, m.leafValidity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issued = true
|
||||
}
|
||||
|
||||
chain := &pki.Chain{Root: root, Intermediate: inter, Leaf: leaf}
|
||||
@@ -209,9 +224,33 @@ func (m *Manager) ensureAuto(pfxPassword string) error {
|
||||
} else {
|
||||
m.logf("Self-managed CA present in the system trust store")
|
||||
}
|
||||
|
||||
// Publish the leaf (with its key) into the host's personal store so other
|
||||
// software that resolves certificates by store lookup — rather than reading
|
||||
// our PEM files — can use it. Re-run on every Ensure so a renewed leaf
|
||||
// replaces the previous one instead of accumulating.
|
||||
m.installLeafToStore(pfxPassword, chain.Leaf.DER, issued)
|
||||
return nil
|
||||
}
|
||||
|
||||
// installLeafToStore imports the current leaf into the host certificate store,
|
||||
// best-effort. Failure is logged, never fatal: it needs administrative rights,
|
||||
// and TLS serving does not depend on it.
|
||||
func (m *Manager) installLeafToStore(pfxPassword string, leafDER []byte, renewed bool) {
|
||||
pfx, err := os.ReadFile(filepath.Join(m.dir, "server.pfx"))
|
||||
if err != nil {
|
||||
m.logf("could not read server.pfx to publish the leaf into the host store: %v", err)
|
||||
return
|
||||
}
|
||||
if err := InstallLeafToMyStore(pfx, pfxPassword, leafDER); err != nil {
|
||||
m.logf("could not publish the server certificate into the host personal store (%v); TLS still works", err)
|
||||
return
|
||||
}
|
||||
if renewed {
|
||||
m.logf("Renewed server certificate published to the host personal store (previous one removed)")
|
||||
}
|
||||
}
|
||||
|
||||
// ensureProvided loads a bring-your-own cert/key (plus optional chain) written
|
||||
// under <dir>/provided by the upload API.
|
||||
func (m *Manager) ensureProvided() error {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
package tlsmgr
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// errWindowsOnly is returned when Windows-store operations are attempted on a
|
||||
// non-Windows platform.
|
||||
@@ -18,3 +21,9 @@ func WindowsStoreSupported() bool { return false }
|
||||
|
||||
// InstallTrustAnchors is a no-op off Windows (no system trust store to manage).
|
||||
func InstallTrustAnchors(rootDER, interDER []byte) error { return nil }
|
||||
|
||||
// InstallLeafToMyStore is a no-op off Windows (no "My" store to publish into).
|
||||
func InstallLeafToMyStore(pfxData []byte, password string, leafDER []byte) error { return nil }
|
||||
|
||||
// RemoveLeafFromMyStore is a no-op off Windows.
|
||||
func RemoveLeafFromMyStore(cert *x509.Certificate) error { return nil }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
package tlsmgr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
@@ -52,6 +53,19 @@ var (
|
||||
const (
|
||||
x509ASNEncoding = 0x00000001
|
||||
certStoreAddReplaceExisting = 3
|
||||
|
||||
// PFXImportCertStore flags. The key is persisted to the machine keyset (so
|
||||
// LocalSystem services can use it) and forced into a CNG KSP, matching the
|
||||
// ncryptSigner path used when serving from the store.
|
||||
//
|
||||
// CRYPT_EXPORTABLE is deliberately NOT set: a non-exportable server key is
|
||||
// the better posture, and it costs nothing here because the store copy is
|
||||
// not the source of truth — the same key lives in <data>/tls/server.key and
|
||||
// server.pfx, so moving the install to another host means copying the data
|
||||
// directory, not extracting the key from the store.
|
||||
cryptMachineKeyset = 0x00000020
|
||||
pkcs12AlwaysCNGKSP = 0x00000200
|
||||
pkcs12AllowOverwriteKey = 0x00004000
|
||||
)
|
||||
|
||||
// InstallTrustAnchors installs the self-managed root and intermediate into the
|
||||
@@ -99,6 +113,167 @@ func addToSystemStore(name string, der []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallLeafToMyStore imports the serving leaf (with its private key) into the
|
||||
// LocalMachine "My" store, so other software on this host — anything that
|
||||
// resolves a certificate by store lookup rather than reading our PEM files —
|
||||
// finds it. pfxData is the PKCS#12 bundle written by pki.Chain.Export, and
|
||||
// leafDER identifies which certificate in that bundle is the leaf (the bundle
|
||||
// also carries the CA chain, which belongs in ROOT/CA instead).
|
||||
//
|
||||
// Renewal is accounted for: the new leaf is added with REPLACE_EXISTING, then
|
||||
// any previous certificate with the same subject *and* the same issuer is
|
||||
// removed, so the store holds exactly one current leaf instead of accumulating
|
||||
// one per renewal. Only certificates issued by our own CA to our own subject
|
||||
// are ever deleted.
|
||||
//
|
||||
// Requires administrative rights (the Windows service runs as LocalSystem).
|
||||
func InstallLeafToMyStore(pfxData []byte, password string, leafDER []byte) error {
|
||||
if len(pfxData) == 0 || len(leafDER) == 0 {
|
||||
return nil
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(leafDER)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing leaf: %w", err)
|
||||
}
|
||||
|
||||
pwPtr, err := windows.UTF16PtrFromString(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blob := windows.CryptDataBlob{Size: uint32(len(pfxData)), Data: &pfxData[0]}
|
||||
imported, err := windows.PFXImportCertStore(&blob, pwPtr,
|
||||
cryptMachineKeyset|pkcs12AllowOverwriteKey|pkcs12AlwaysCNGKSP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("importing PFX: %w", err)
|
||||
}
|
||||
defer windows.CertCloseStore(imported, 0)
|
||||
|
||||
// Locate the leaf inside the imported bundle by exact DER match.
|
||||
leafCtx := findContextByDER(imported, leafDER)
|
||||
if leafCtx == nil {
|
||||
return fmt.Errorf("leaf certificate not found in the imported PFX")
|
||||
}
|
||||
defer windows.CertFreeCertificateContext(leafCtx)
|
||||
|
||||
namePtr, err := windows.UTF16PtrFromString("MY")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Read-write handle (no readonly flag) — this is the one place we modify My.
|
||||
my, err := windows.CertOpenStore(
|
||||
certStoreProvSystemW, 0, 0, certSystemStoreLocalMac,
|
||||
uintptr(unsafe.Pointer(namePtr)),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening My store: %w", err)
|
||||
}
|
||||
defer windows.CertCloseStore(my, 0)
|
||||
|
||||
if err := windows.CertAddCertificateContextToStore(my, leafCtx, certStoreAddReplaceExisting, nil); err != nil {
|
||||
return fmt.Errorf("adding leaf to My store: %w", err)
|
||||
}
|
||||
|
||||
pruneSupersededLeaves(my, leaf)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveLeafFromMyStore deletes a previously installed leaf (and any sibling
|
||||
// sharing its subject and issuer) from the LocalMachine "My" store. Used when
|
||||
// the service is removed, so uninstalling does not leave an orphaned
|
||||
// certificate behind. Best-effort; a missing certificate is not an error.
|
||||
func RemoveLeafFromMyStore(cert *x509.Certificate) error {
|
||||
if cert == nil {
|
||||
return nil
|
||||
}
|
||||
namePtr, err := windows.UTF16PtrFromString("MY")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
my, err := windows.CertOpenStore(
|
||||
certStoreProvSystemW, 0, 0, certSystemStoreLocalMac,
|
||||
uintptr(unsafe.Pointer(namePtr)),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening My store: %w", err)
|
||||
}
|
||||
defer windows.CertCloseStore(my, 0)
|
||||
|
||||
// Delete the certificate itself along with any same-subject/issuer sibling:
|
||||
// pruneSupersededLeaves keeps anything matching cert.Raw, so pass a copy
|
||||
// whose Raw can never match a stored entry.
|
||||
if ctx := findContextByDER(my, cert.Raw); ctx != nil {
|
||||
_ = windows.CertDeleteCertificateFromStore(ctx)
|
||||
}
|
||||
sentinel := *cert
|
||||
sentinel.Raw = nil
|
||||
pruneSupersededLeaves(my, &sentinel)
|
||||
return nil
|
||||
}
|
||||
|
||||
// findContextByDER returns the context in store whose encoded certificate
|
||||
// equals der, or nil. The returned context is owned by the caller.
|
||||
func findContextByDER(store windows.Handle, der []byte) *windows.CertContext {
|
||||
var prev *windows.CertContext
|
||||
for {
|
||||
ctx, err := windows.CertEnumCertificatesInStore(store, prev)
|
||||
if ctx == nil || err != nil {
|
||||
return nil
|
||||
}
|
||||
if bytes.Equal(contextDER(ctx), der) {
|
||||
// Duplicate so the enumeration can be torn down without freeing
|
||||
// the context we are handing back.
|
||||
dup := windows.CertDuplicateCertificateContext(ctx)
|
||||
freeEnum(ctx)
|
||||
return dup
|
||||
}
|
||||
prev = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// pruneSupersededLeaves removes certificates in store that share the current
|
||||
// leaf's subject and issuer but not its thumbprint — i.e. leaves this CA issued
|
||||
// on a previous renewal. Anything issued by another CA, or to another subject,
|
||||
// is left strictly alone. Best-effort: a failed delete is not fatal.
|
||||
func pruneSupersededLeaves(store windows.Handle, current *x509.Certificate) {
|
||||
// Collect first, delete afterwards: CertDeleteCertificateFromStore frees the
|
||||
// context it is given, which would invalidate the running enumeration.
|
||||
var doomed []*windows.CertContext
|
||||
var prev *windows.CertContext
|
||||
for {
|
||||
ctx, err := windows.CertEnumCertificatesInStore(store, prev)
|
||||
if ctx == nil || err != nil {
|
||||
break
|
||||
}
|
||||
der := contextDER(ctx)
|
||||
if !bytes.Equal(der, current.Raw) {
|
||||
if c, perr := x509.ParseCertificate(der); perr == nil &&
|
||||
bytes.Equal(c.RawSubject, current.RawSubject) &&
|
||||
bytes.Equal(c.RawIssuer, current.RawIssuer) {
|
||||
doomed = append(doomed, windows.CertDuplicateCertificateContext(ctx))
|
||||
}
|
||||
}
|
||||
prev = ctx
|
||||
}
|
||||
for _, ctx := range doomed {
|
||||
// Delete frees the context whether it succeeds or fails.
|
||||
_ = windows.CertDeleteCertificateFromStore(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// freeEnum releases a certificate context obtained mid-enumeration, when the
|
||||
// caller stops early instead of enumerating to completion.
|
||||
//
|
||||
// CertEnumCertificatesInStore frees the context passed as pPrevCertContext on
|
||||
// each call and frees the last one when it returns NULL, so a loop that runs to
|
||||
// completion leaks nothing. Returning early leaves the current context live —
|
||||
// and because CertCloseStore(store, 0) defers the actual close until every
|
||||
// outstanding context is released, that leaks the store handle too.
|
||||
func freeEnum(ctx *windows.CertContext) {
|
||||
if ctx != nil {
|
||||
windows.CertFreeCertificateContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// prevKey holds the NCrypt key handle currently in use so it can be released
|
||||
// when the manager reloads to a new certificate.
|
||||
var (
|
||||
@@ -204,13 +379,18 @@ func (m *Manager) ensureWindowsStore(thumbprint string) error {
|
||||
continue
|
||||
}
|
||||
// Match. Acquire the CNG key (caller-owned) then release the context
|
||||
// and store; the key handle stays valid on its own.
|
||||
// and store; the key handle stays valid on its own. The context must
|
||||
// be freed explicitly here: we are leaving the enumeration early, so
|
||||
// CertEnumCertificatesInStore will never free it for us, and the
|
||||
// store handle would stay open behind it.
|
||||
cert, perr := x509.ParseCertificate(der)
|
||||
if perr != nil {
|
||||
freeEnum(ctx)
|
||||
windows.CertCloseStore(store, 0)
|
||||
return fmt.Errorf("parsing store certificate: %w", perr)
|
||||
}
|
||||
key, kerr := acquireNCryptKey(ctx)
|
||||
freeEnum(ctx)
|
||||
windows.CertCloseStore(store, 0)
|
||||
if kerr != nil {
|
||||
return kerr
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build windows
|
||||
|
||||
package tlsmgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Grace-Solutions/OrchestrAD/internal/pki"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// elevated reports whether this process can write to LocalMachine stores.
|
||||
func elevated() bool {
|
||||
var sid *windows.SID
|
||||
if err := windows.AllocateAndInitializeSid(
|
||||
&windows.SECURITY_NT_AUTHORITY, 2,
|
||||
windows.SECURITY_BUILTIN_DOMAIN_RID, windows.DOMAIN_ALIAS_RID_ADMINS,
|
||||
0, 0, 0, 0, 0, 0, &sid); err != nil {
|
||||
return false
|
||||
}
|
||||
defer windows.FreeSid(sid)
|
||||
member, err := windows.Token(0).IsMember(sid)
|
||||
return err == nil && member
|
||||
}
|
||||
|
||||
// TestInstallLeafToMyStoreReplacesOnRenewal covers the renewal path: a second
|
||||
// leaf issued by the same CA to the same subject must supersede the first
|
||||
// rather than accumulate, and certificates from other issuers must be left
|
||||
// alone. Skipped without admin rights, since it writes to LocalMachine\My.
|
||||
func TestInstallLeafToMyStoreReplacesOnRenewal(t *testing.T) {
|
||||
if !elevated() {
|
||||
t.Skip("needs administrative rights to write LocalMachine\\My")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
root, inter, err := pki.NewCA("OrchestrAD Test Root CA", "OrchestrAD Test Intermediate CA")
|
||||
if err != nil {
|
||||
t.Fatalf("NewCA: %v", err)
|
||||
}
|
||||
|
||||
// Unique CN so the test can never collide with a real certificate and its
|
||||
// pruning can only ever touch its own leaves.
|
||||
cn := "orchestrad-store-test-" + time.Now().UTC().Format("20060102150405.000000")
|
||||
|
||||
issue := func() *pki.CertKey {
|
||||
t.Helper()
|
||||
leaf, err := pki.IssueLeaf(inter, cn, []string{cn}, nil, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueLeaf: %v", err)
|
||||
}
|
||||
chain := &pki.Chain{Root: root, Intermediate: inter, Leaf: leaf}
|
||||
if err := chain.Export(dir, "testpw"); err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
pfx, err := os.ReadFile(filepath.Join(dir, "server.pfx"))
|
||||
if err != nil {
|
||||
t.Fatalf("read pfx: %v", err)
|
||||
}
|
||||
if err := InstallLeafToMyStore(pfx, "testpw", leaf.DER); err != nil {
|
||||
t.Fatalf("InstallLeafToMyStore: %v", err)
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
countOurs := func() (int, string) {
|
||||
t.Helper()
|
||||
certs, err := ListWindowsCerts()
|
||||
if err != nil {
|
||||
t.Fatalf("ListWindowsCerts: %v", err)
|
||||
}
|
||||
// Match on the CN substring: IssueLeaf also sets an Organization, so the
|
||||
// rendered subject is "CN=<cn>,O=OrchestrAD".
|
||||
n, thumb := 0, ""
|
||||
for _, c := range certs {
|
||||
if strings.Contains(c.Subject, cn) {
|
||||
n++
|
||||
thumb = c.Thumbprint
|
||||
}
|
||||
}
|
||||
return n, thumb
|
||||
}
|
||||
|
||||
first := issue()
|
||||
t.Cleanup(func() { _ = RemoveLeafFromMyStore(first.Certificate) })
|
||||
|
||||
n, firstThumb := countOurs()
|
||||
if n != 1 {
|
||||
t.Fatalf("after first install: %d certificates with our CN, want 1", n)
|
||||
}
|
||||
|
||||
// Renew: a different leaf, same subject and issuer.
|
||||
second := issue()
|
||||
t.Cleanup(func() { _ = RemoveLeafFromMyStore(second.Certificate) })
|
||||
|
||||
n, secondThumb := countOurs()
|
||||
if n != 1 {
|
||||
t.Errorf("after renewal: %d certificates with our CN, want 1 (the old one should be pruned)", n)
|
||||
}
|
||||
if secondThumb == firstThumb {
|
||||
t.Errorf("renewal did not replace the stored certificate (thumbprint unchanged)")
|
||||
}
|
||||
}
|
||||
@@ -24,16 +24,16 @@ const (
|
||||
type ConditionOperator string
|
||||
|
||||
const (
|
||||
OperatorEquals ConditionOperator = "Equals"
|
||||
OperatorNotEquals ConditionOperator = "NotEquals"
|
||||
OperatorContains ConditionOperator = "Contains"
|
||||
OperatorStartsWith ConditionOperator = "StartsWith"
|
||||
OperatorEndsWith ConditionOperator = "EndsWith"
|
||||
OperatorRegex ConditionOperator = "Regex"
|
||||
OperatorExists ConditionOperator = "Exists"
|
||||
OperatorNotExists ConditionOperator = "NotExists"
|
||||
OperatorEquals ConditionOperator = "Equals"
|
||||
OperatorNotEquals ConditionOperator = "NotEquals"
|
||||
OperatorContains ConditionOperator = "Contains"
|
||||
OperatorStartsWith ConditionOperator = "StartsWith"
|
||||
OperatorEndsWith ConditionOperator = "EndsWith"
|
||||
OperatorRegex ConditionOperator = "Regex"
|
||||
OperatorExists ConditionOperator = "Exists"
|
||||
OperatorNotExists ConditionOperator = "NotExists"
|
||||
OperatorGreaterThan ConditionOperator = "GreaterThan"
|
||||
OperatorLessThan ConditionOperator = "LessThan"
|
||||
OperatorLessThan ConditionOperator = "LessThan"
|
||||
// OperatorMemberOf matches objects that are direct members of a group.
|
||||
OperatorMemberOf ConditionOperator = "MemberOf"
|
||||
// OperatorMemberOfRecursive matches objects that are members of a group
|
||||
@@ -47,10 +47,10 @@ const (
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
ActionMoveToOu ActionType = "MoveToOu"
|
||||
ActionAddToGroup ActionType = "AddToGroup"
|
||||
ActionAddGroupToGroup ActionType = "AddGroupToGroup"
|
||||
ActionEnsureGroupExists ActionType = "EnsureGroupExists"
|
||||
ActionMoveToOu ActionType = "MoveToOu"
|
||||
ActionAddToGroup ActionType = "AddToGroup"
|
||||
ActionAddGroupToGroup ActionType = "AddGroupToGroup"
|
||||
ActionEnsureGroupExists ActionType = "EnsureGroupExists"
|
||||
ActionRemoveFromGroupIfNoMatch ActionType = "RemoveFromGroupIfNoLongerMatched"
|
||||
// ActionSyncGroupMembership reconciles a target group's membership against
|
||||
// the matched object set as a single set operation: add matched objects
|
||||
|
||||
@@ -9,8 +9,8 @@ import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
import { IconMenu2 } from "@tabler/icons-react";
|
||||
import About from "../../vertical/header/About";
|
||||
import ApiDocsLink from "../../vertical/header/ApiDocsLink";
|
||||
import Notifications from "../../vertical/header/Notification";
|
||||
import Profile from "../../vertical/header/Profile";
|
||||
import Search from "../../vertical/header/Search";
|
||||
import Logo from "../../shared/logo/Logo";
|
||||
@@ -85,7 +85,7 @@ export default function Header() {
|
||||
)}
|
||||
</IconButton>
|
||||
<ApiDocsLink />
|
||||
<Notifications />
|
||||
<About />
|
||||
<Profile />
|
||||
</Stack>
|
||||
</ToolbarStyled>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ServerVersion {
|
||||
version: string;
|
||||
buildTime: string;
|
||||
gitCommit: string;
|
||||
}
|
||||
|
||||
// The /api/v1/version probe predates the {success,data} envelope the rest of
|
||||
// the API uses and returns bare snake_case JSON, so it is fetched directly
|
||||
// rather than through the api client (which requires the envelope). Both
|
||||
// shapes are accepted in case that endpoint is ever normalised.
|
||||
async function fetchVersion(signal: AbortSignal): Promise<ServerVersion> {
|
||||
const res = await fetch("/api/v1/version", { signal });
|
||||
if (!res.ok) throw new Error(`version request failed (${res.status})`);
|
||||
const body = await res.json();
|
||||
const v = body?.data ?? body ?? {};
|
||||
return {
|
||||
version: v.version ?? v.Version ?? "unknown",
|
||||
buildTime: v.build_time ?? v.buildTime ?? "",
|
||||
gitCommit: v.git_commit ?? v.gitCommit ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function formatBuildTime(raw: string): string {
|
||||
if (!raw || raw === "unknown") return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString();
|
||||
}
|
||||
|
||||
// About shows the running server's build so an operator can confirm which
|
||||
// version is deployed — and quote it verbatim in a bug report — without
|
||||
// shelling onto the host.
|
||||
export default function About() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [info, setInfo] = useState<ServerVersion | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const controller = new AbortController();
|
||||
setError(null);
|
||||
fetchVersion(controller.signal)
|
||||
.then(setInfo)
|
||||
.catch((err) => {
|
||||
if (err?.name !== "AbortError") setError("Could not read the server version.");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [open]);
|
||||
|
||||
const summary = info
|
||||
? `OrchestrAD ${info.version}${info.gitCommit ? ` (${info.gitCommit})` : ""}`
|
||||
: "";
|
||||
|
||||
const rows: Array<[string, string]> = info
|
||||
? [
|
||||
["Version", info.version],
|
||||
["Built", formatBuildTime(info.buildTime)],
|
||||
["Commit", info.gitCommit || "—"],
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="About OrchestrAD">
|
||||
<IconButton size="large" color="inherit" aria-label="About OrchestrAD" onClick={() => setOpen(true)}>
|
||||
<Icon icon="solar:info-circle-line-duotone" width="21" height="21" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>About OrchestrAD</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
Active Directory rule automation.
|
||||
</Typography>
|
||||
|
||||
{error && <Typography variant="body2" color="error">{error}</Typography>}
|
||||
|
||||
{!info && !error && (
|
||||
<Typography variant="body2" color="textSecondary">Reading server version…</Typography>
|
||||
)}
|
||||
|
||||
{info && (
|
||||
<Stack spacing={1.25}>
|
||||
{rows.map(([label, value]) => (
|
||||
<Stack key={label} direction="row" spacing={2} alignItems="baseline">
|
||||
<Typography variant="caption" color="textSecondary" sx={{ minWidth: 72 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
API reference:{" "}
|
||||
<a href="/api/docs" target="_blank" rel="noopener noreferrer">/api/docs</a>
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{info && (
|
||||
<Button
|
||||
startIcon={<ContentCopyIcon sx={{ fontSize: 16 }} />}
|
||||
onClick={() => { void navigator.clipboard?.writeText(summary).catch(() => {}); }}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Grid } from '@mui/material';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import * as dropdownData from './data';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
const AppLinks = () => {
|
||||
return (
|
||||
(<Grid container spacing={3} mb={4}>
|
||||
{dropdownData.appsLink.map((links, index) => (
|
||||
<Grid
|
||||
key={index}
|
||||
size={{
|
||||
lg: 6
|
||||
}}>
|
||||
<Link href={links.href} className="hover-text-primary">
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Box
|
||||
minWidth="45px"
|
||||
height="45px"
|
||||
bgcolor="grey.100"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Avatar
|
||||
src={links.avatar}
|
||||
alt={links.avatar}
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={600}
|
||||
color="textPrimary"
|
||||
noWrap
|
||||
className="text-hover"
|
||||
sx={{
|
||||
width: '240px',
|
||||
}}
|
||||
>
|
||||
{links.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
variant="subtitle2"
|
||||
fontSize="12px"
|
||||
sx={{
|
||||
width: '240px',
|
||||
}}
|
||||
noWrap
|
||||
>
|
||||
{links.subtext}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Link>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>)
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLinks;
|
||||
@@ -8,8 +8,8 @@ import { styled } from '@mui/material/styles';
|
||||
import config from '@/app/context/config'
|
||||
import { useContext } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import About from "./About";
|
||||
import ApiDocsLink from "./ApiDocsLink";
|
||||
import Notifications from "./Notification";
|
||||
import Profile from "./Profile";
|
||||
import Search from "./Search";
|
||||
|
||||
@@ -88,7 +88,7 @@ const Header = () => {
|
||||
</IconButton>
|
||||
|
||||
<ApiDocsLink />
|
||||
<Notifications />
|
||||
<About />
|
||||
<Profile />
|
||||
</Stack>
|
||||
</ToolbarStyled>
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import * as dropdownData from "./data";
|
||||
import Scrollbar from "@/app/components/custom-scroll/Scrollbar";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Stack } from "@mui/system";
|
||||
import Link from "next/link";
|
||||
|
||||
const Notifications = () => {
|
||||
const [anchorEl2, setAnchorEl2] = useState<HTMLElement | null>(null);
|
||||
|
||||
const handleClick2 = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl2(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose2 = () => {
|
||||
setAnchorEl2(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
size="large"
|
||||
aria-label="show 11 new notifications"
|
||||
aria-controls="msgs-menu"
|
||||
aria-haspopup="true"
|
||||
className="btn-rounded-circle-40"
|
||||
color="inherit"
|
||||
onClick={handleClick2}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
top: "5px",
|
||||
animationName: "pulse",
|
||||
}}
|
||||
>
|
||||
<Icon icon="solar:bell-bing-line-duotone" width="24" height="24" />
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "-14px",
|
||||
right: "-5px",
|
||||
height: "18px",
|
||||
width: "18px",
|
||||
zIndex: "10",
|
||||
border: "2px solid #4bd08b",
|
||||
borderRadius: "70px",
|
||||
animationIterationCount: "infinite !important",
|
||||
animation: "heartbit 1s ease-out"
|
||||
}}
|
||||
></Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: "4px",
|
||||
height: "4px",
|
||||
borderRadius: "30px",
|
||||
position: "absolute",
|
||||
right: "2px",
|
||||
top: "-7px",
|
||||
backgroundColor: "success.main"
|
||||
}}
|
||||
></Box>
|
||||
</Box>
|
||||
</Button>
|
||||
{/* ------------------------------------------- */}
|
||||
{/* Message Dropdown */}
|
||||
{/* ------------------------------------------- */}
|
||||
<Menu
|
||||
id="msgs-menu"
|
||||
anchorEl={anchorEl2}
|
||||
keepMounted
|
||||
open={Boolean(anchorEl2)}
|
||||
onClose={handleClose2}
|
||||
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
|
||||
transformOrigin={{ horizontal: "right", vertical: "top" }}
|
||||
sx={{
|
||||
"& .MuiMenu-paper": {
|
||||
width: "360px",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
py={2}
|
||||
px={4}
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography variant="h6">Notifications</Typography>
|
||||
<Chip label="5 new" color="primary" size="small" />
|
||||
</Stack>
|
||||
<Scrollbar sx={{ height: "385px" }}>
|
||||
{dropdownData.notifications.map((notification, index) => (
|
||||
<Box key={index}>
|
||||
<MenuItem sx={{ py: 2, px: 4 }}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Avatar
|
||||
src={notification.avatar}
|
||||
alt={notification.avatar}
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="textPrimary"
|
||||
fontWeight={600}
|
||||
noWrap
|
||||
sx={{
|
||||
width: "240px",
|
||||
}}
|
||||
>
|
||||
{notification.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
width: "240px",
|
||||
}}
|
||||
noWrap
|
||||
>
|
||||
{notification.subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
))}
|
||||
</Scrollbar>
|
||||
<Box p={3} pb={1}>
|
||||
<Button
|
||||
href="/apps/email"
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
color="primary"
|
||||
fullWidth
|
||||
>
|
||||
See all Notifications
|
||||
</Button>
|
||||
</Box>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
@@ -45,7 +45,7 @@ const Profile = () => {
|
||||
<Box>
|
||||
<Button
|
||||
size="large"
|
||||
aria-label="show 11 new notifications"
|
||||
aria-label="Account menu"
|
||||
color="inherit"
|
||||
aria-controls="msgs-menu"
|
||||
aria-haspopup="true"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import * as dropdownData from './data';
|
||||
import Link from 'next/link';
|
||||
|
||||
const QuickLinks = () => {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5">Quick Links</Typography>
|
||||
<Stack spacing={2} mt={2}>
|
||||
{dropdownData.pageLinks.map((pagelink, index) => (
|
||||
<Link href={pagelink.href} key={index} className="hover-text-primary">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="textPrimary"
|
||||
className="text-hover"
|
||||
fontWeight={600}
|
||||
>
|
||||
{pagelink.title}
|
||||
</Typography>
|
||||
</Link>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickLinks;
|
||||
@@ -1,191 +0,0 @@
|
||||
// Notifications dropdown
|
||||
|
||||
interface notificationType {
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const notifications: notificationType[] = [
|
||||
{
|
||||
avatar: "/images/profile/user-1.jpg",
|
||||
title: "Roman Joined the Team!",
|
||||
subtitle: "Congratulate him",
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user-2.jpg",
|
||||
title: "New message received",
|
||||
subtitle: "Salma sent you new message",
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user-3.jpg",
|
||||
title: "New Payment received",
|
||||
subtitle: "Check your earnings",
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user-4.jpg",
|
||||
title: "Jolly completed tasks",
|
||||
subtitle: "Assign her new tasks",
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user-1.jpg",
|
||||
title: "Roman Joined the Team!",
|
||||
subtitle: "Congratulate him",
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user-2.jpg",
|
||||
title: "New message received",
|
||||
subtitle: "Salma sent you new message",
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user-3.jpg",
|
||||
title: "New Payment received",
|
||||
subtitle: "Check your earnings",
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user-4.jpg",
|
||||
title: "Jolly completed tasks",
|
||||
subtitle: "Assign her new tasks",
|
||||
},
|
||||
];
|
||||
|
||||
// Messages dropdown
|
||||
|
||||
interface messageType {
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const messages: messageType[] = [
|
||||
{
|
||||
avatar: "/images/profile/user1.jpg",
|
||||
title: "Roman Joined the Team!",
|
||||
subtitle: "Congratulate him",
|
||||
time: '9:08 AM'
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user2.jpg",
|
||||
title: "New message received",
|
||||
subtitle: "Salma sent you new message",
|
||||
time: '19:08 PM'
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user3.jpg",
|
||||
title: "New Payment received",
|
||||
subtitle: "Check your earnings",
|
||||
time: '4:15 AM'
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user4.jpg",
|
||||
title: "Jolly completed tasks",
|
||||
subtitle: "Assign her new tasks",
|
||||
time: '9:08 AM'
|
||||
},
|
||||
{
|
||||
avatar: "/images/profile/user5.jpg",
|
||||
title: "Roman Joined the Team!",
|
||||
subtitle: "Congratulate him",
|
||||
time: '12:08 AM'
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// apps dropdown
|
||||
|
||||
interface appsLinkType {
|
||||
href: string;
|
||||
title: string;
|
||||
subtext: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
const appsLink: appsLinkType[] = [
|
||||
{
|
||||
href: "/apps/chats",
|
||||
title: "Chat Application",
|
||||
subtext: "New messages arrived",
|
||||
avatar: "/images/svgs/icon-dd-chat.svg",
|
||||
},
|
||||
{
|
||||
href: "/apps/ecommerce/shop",
|
||||
title: "eCommerce App",
|
||||
subtext: "New stock available",
|
||||
avatar: "/images/svgs/icon-dd-cart.svg",
|
||||
},
|
||||
{
|
||||
href: "/apps/notes",
|
||||
title: "Notes App",
|
||||
subtext: "To-do and Daily tasks",
|
||||
avatar: "/images/svgs/icon-dd-invoice.svg",
|
||||
},
|
||||
{
|
||||
href: "/apps/calendar",
|
||||
title: "Calendar App",
|
||||
subtext: "Get dates",
|
||||
avatar: "/images/svgs/icon-dd-date.svg",
|
||||
},
|
||||
{
|
||||
href: "/apps/contacts",
|
||||
title: "Contact Application",
|
||||
subtext: "2 Unsaved Contacts",
|
||||
avatar: "/images/svgs/icon-dd-mobile.svg",
|
||||
},
|
||||
{
|
||||
href: "/apps/tickets",
|
||||
title: "Tickets App",
|
||||
subtext: "Submit tickets",
|
||||
avatar: "/images/svgs/icon-dd-lifebuoy.svg",
|
||||
},
|
||||
{
|
||||
href: "/apps/email",
|
||||
title: "Email App",
|
||||
subtext: "Get new emails",
|
||||
avatar: "/images/svgs/icon-dd-message-box.svg",
|
||||
},
|
||||
{
|
||||
href: "/apps/blog/post",
|
||||
title: "Blog App",
|
||||
subtext: "added new blog",
|
||||
avatar: "/images/svgs/icon-dd-application.svg",
|
||||
},
|
||||
];
|
||||
|
||||
interface LinkType {
|
||||
href: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const pageLinks: LinkType[] = [
|
||||
{
|
||||
href: "/theme-pages/pricing",
|
||||
title: "Pricing Page",
|
||||
},
|
||||
{
|
||||
href: "/login",
|
||||
title: "Sign In",
|
||||
},
|
||||
{
|
||||
href: "/404",
|
||||
title: "404 Error Page",
|
||||
},
|
||||
{
|
||||
href: "/apps/note",
|
||||
title: "Notes App",
|
||||
},
|
||||
{
|
||||
href: "/apps/user-profile/profile",
|
||||
title: "User Application",
|
||||
},
|
||||
{
|
||||
href: "/apps/blog/post",
|
||||
title: "Blog Design",
|
||||
},
|
||||
{
|
||||
href: "/apps/ecommerce/checkout",
|
||||
title: "Shopping Cart",
|
||||
},
|
||||
];
|
||||
|
||||
export { notifications, pageLinks, appsLink, messages };
|
||||
Reference in New Issue
Block a user