4 Commits

Author SHA1 Message Date
Alphaeus Mote 39b39740a3 feat(ui): replace the notifications bell with an About dialog
The bell rendered hard-coded template data ("Roman Joined the Team!",
"New Payment received") — a demo artifact that had nothing to do with the
product and no backing feature.

In its place, an About dialog reports the running server's build: version,
build time, and commit, read from GET /api/v1/version, with a copy button so
the exact build can be quoted in a bug report without shelling onto the host.
That probe predates the {success,data} envelope and returns bare snake_case
JSON, so it is fetched directly rather than through the api client, accepting
either shape in case it is ever normalised. Dev builds ("dev"/"unknown")
degrade to a readable "—" rather than an invalid date.

Removing the bell also stranded three other unreferenced template files, so
Notification.tsx, AppLinks.tsx, QuickLinks.tsx and their shared data.ts (fake
users, chat/ecommerce app links) are deleted with it.

Also fixes the account button announcing itself to screen readers as
"show 11 new notifications".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 20:55:27 -04:00
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
Addresses the gaps identified in the last audit.

Restore (was a stub returning "not yet implemented"). Every repository shares
one connection pool, so the database cannot be swapped underneath a live
server. Restore is therefore two-phase: RestoreBackup validates the file and
stages it beside the database; db.New applies it before the pool is opened,
which is the only safe moment. The database being replaced is preserved as
<db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot
replay the old journal over the restored file. Validation is strict — SQLite
integrity_check plus a schema probe — because applying an unrelated file
would destroy the install. GET/DELETE /api/v1/backups/restore inspect and
cancel a staged restore. The CLI does both phases at once, since it runs
standalone; `orchestrad backup` was also a stub and now works.

Secret key. With nothing configured the key is generated once and persisted
to <data>/secret.key, so restarts reuse it and moving the stack to another
server is a matter of copying the data directory. Upgrades are handled: if a
database already exists the install was silently running on the legacy
built-in default, so that value is adopted and written out rather than
replaced — generating a fresh key there would make every stored credential
undecryptable. The file is owner-only (ACL-restricted on Windows).

Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the
architectures the release binaries already covered. The Dockerfile
cross-compiles via TARGETARCH rather than emulating, so arm64 costs little.

CSRF: the middleware previously checked only that a header was *present* and
was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens
are now nonce + HMAC-SHA256 signed with the application secret, validated
properly, and the middleware is mounted on /api/v1. Bearer and API-key
requests are not CSRF-reachable and pass through untouched, so this is
transparent to the SPA and to API clients.

Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not
the source of truth — <data>/tls holds the key, so portability is unaffected
and a non-exportable server key is the better posture), the PFX password is
written to server.pfx.password beside the bundle so an operator importing it
by hand does not have to hunt for a password they never chose, and the
"renewed" log line now reflects whether a leaf was actually issued instead of
guessing from its age.

Verified live: backup -> stage -> restart applies and preserves the previous
database; secret key generated, adopted, and read back across restarts with
the credential check confirming decryptability; CSRF endpoint issues real
signed tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:57:15 -04:00
Alphaeus Mote ba8808b6ab fix(tls): serialize Ensure/Reload
Ensure is reachable concurrently from the renewal loop (Manager.Start) and
the TLS settings handler (Reload on a config change). Nothing serialized
them, so an overlapping pair could issue leaves, rewrite <dir>, and mutate
the host certificate store underneath each other — and since the store prune
removes any superseded leaf, a racing pair could delete the very certificate
the other just installed. Guard the whole operation with a mutex.

The file-rewrite half of this race predates the store work; publishing to the
certificate store is what made the consequence bad enough to matter.

Also drops an unused parameter from the freeEnum helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:40:17 -04:00
Alphaeus Mote a0c937b876 feat(tls): publish the serving leaf to the Windows My store; fix context leak
Clients that resolve a certificate by store lookup rather than by reading our
PEM files had nothing to find: in auto mode the leaf lived only under
<data>/tls, and the My store was opened strictly read-only.

InstallLeafToMyStore imports the exported PKCS#12 into LocalMachine\My with
the private key persisted to the machine keyset (CNG KSP, matching the
ncryptSigner path used when serving *from* the store). Renewal is accounted
for: the new leaf is added with REPLACE_EXISTING, then pruneSupersededLeaves
removes any certificate sharing its subject *and* issuer, so the store holds
exactly one current leaf instead of one per renewal. Only certificates issued
by our own CA to our own subject are ever deleted - anything from another
issuer is left strictly alone. Best-effort: it needs admin rights and TLS
serving does not depend on it.

RemoveLeafFromMyStore runs on service removal, alongside the firewall rule,
so uninstalling leaves no orphaned certificate.

Also fixes a genuine leak found while auditing this code, in response to a
question about whether listing the store could damage it (it cannot - the
listing handle is read-only and stores are not exclusively locked):
ensureWindowsStore returned from inside the enumeration without freeing the
matched CertContext. CertEnumCertificatesInStore frees the previous context
each call and the last on completion, so only the early-return paths leaked -
and because CertCloseStore(store, 0) defers until outstanding contexts are
released, the store handle leaked with it, once per certificate load.

Verified on Windows 11: leaf appears in LocalMachine\My with a usable private
key and full SANs; forcing a re-issue replaces it (one cert, new thumbprint,
old one pruned) and leaves unrelated certificates untouched; HTTPS keeps
serving throughout. Cross-compiles for linux via the no-op stubs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:23:03 -04:00
38 changed files with 1575 additions and 587 deletions
+23 -7
View File
@@ -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
View File
@@ -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} \
+24 -1
View File
@@ -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 {
+99
View File
@@ -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)
})
}
+104
View File
@@ -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)
}
}
+2 -32
View File
@@ -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.
+2 -2
View File
@@ -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.",
},
+2 -2
View File
@@ -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"`
}
+74 -7
View File
@@ -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
+27
View File
@@ -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")
}
+22 -16
View File
@@ -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,
+105
View File
@@ -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)
}
+118
View File
@@ -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
}
+9
View File
@@ -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")
+175
View File
@@ -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()
}
+170
View File
@@ -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)
}
}
+6 -6
View File
@@ -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
+8
View File
@@ -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
}
+3 -3
View File
@@ -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
+21 -21
View File
@@ -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
}
+11 -1
View File
@@ -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.
+27 -16
View File
@@ -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 {
+39
View File
@@ -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 {
+10 -1
View File
@@ -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 }
+181 -1
View File
@@ -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)")
}
}
+13 -13
View File
@@ -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 };