@@ -0,0 +1,456 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gpo-distributor/internal/bundle"
|
||||
"gpo-distributor/internal/model"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
Profile string `json:"profile"`
|
||||
ClientToken string `json:"client_token"`
|
||||
SigningKey string `json:"signing_key"`
|
||||
LGPOPath string `json:"lgpo_path"`
|
||||
StateDir string `json:"state_dir"`
|
||||
PollInterval string `json:"poll_interval"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty"`
|
||||
RequestTimeout string `json:"request_timeout,omitempty"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Generation string `json:"generation"`
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
Policies []model.ResolvedPolicy `json:"policies"`
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
logger *log.Logger
|
||||
version string
|
||||
interval time.Duration
|
||||
clientID string
|
||||
hostname string
|
||||
stateFile string
|
||||
}
|
||||
|
||||
func LoadConfig(filename string) (Config, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||||
var cfg Config
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func New(cfg Config, version string, logger *log.Logger) (*Runner, error) {
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
interval, err := time.ParseDuration(cfg.PollInterval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poll_interval: %w", err)
|
||||
}
|
||||
if interval < time.Minute {
|
||||
return nil, errors.New("poll_interval must be at least 1m")
|
||||
}
|
||||
timeout := 10 * time.Minute
|
||||
if cfg.RequestTimeout != "" {
|
||||
timeout, err = time.ParseDuration(cfg.RequestTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request_timeout: %w", err)
|
||||
}
|
||||
}
|
||||
if logger == nil {
|
||||
logger = log.Default()
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
clientID := cfg.ClientID
|
||||
if clientID == "" {
|
||||
clientID = host
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: cfg.InsecureSkipVerify} // #nosec G402: explicit opt-in for test environments.
|
||||
return &Runner{
|
||||
cfg: cfg, version: version, logger: logger, interval: interval,
|
||||
client: &http.Client{Timeout: timeout, Transport: transport},
|
||||
clientID: clientID, hostname: host,
|
||||
stateFile: filepath.Join(cfg.StateDir, "state.json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateConfig(cfg Config) error {
|
||||
if cfg.ServerURL == "" || cfg.Profile == "" || cfg.ClientToken == "" || cfg.SigningKey == "" || cfg.LGPOPath == "" || cfg.StateDir == "" {
|
||||
return errors.New("server_url, profile, client_token, signing_key, lgpo_path and state_dir are required")
|
||||
}
|
||||
u, err := url.Parse(cfg.ServerURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return errors.New("server_url must be an absolute URL")
|
||||
}
|
||||
if u.Scheme != "https" && !cfg.InsecureSkipVerify {
|
||||
return errors.New("server_url must use https (or set insecure_skip_verify only for tests)")
|
||||
}
|
||||
if cfg.PollInterval == "" {
|
||||
return errors.New("poll_interval is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, once bool) error {
|
||||
if err := os.MkdirAll(r.cfg.StateDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if once {
|
||||
return r.Sync(ctx)
|
||||
}
|
||||
ticker := time.NewTicker(r.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := r.Sync(ctx); err != nil {
|
||||
r.logger.Printf("sync failed: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) Sync(ctx context.Context) (retErr error) {
|
||||
state, err := r.loadState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest, changed, err := r.fetchManifest(ctx, state.Generation)
|
||||
if err != nil {
|
||||
r.report(ctx, model.ClientReport{ClientID: r.clientID, Hostname: r.hostname, Profile: r.cfg.Profile, Generation: state.Generation, Success: false, Message: err.Error(), AgentVersion: r.version, OperatingSystem: runtime.GOOS})
|
||||
return err
|
||||
}
|
||||
if !changed {
|
||||
r.logger.Printf("profile=%s generation=%s status=up-to-date", r.cfg.Profile, state.Generation)
|
||||
return nil
|
||||
}
|
||||
|
||||
unlock, err := acquireLock(filepath.Join(r.cfg.StateDir, "agent.lock"), 2*time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
staging, err := os.MkdirTemp(r.cfg.StateDir, "staging-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
|
||||
policyDirs := make([]string, 0, len(manifest.Policies))
|
||||
for i, p := range manifest.Policies {
|
||||
zipPath, err := r.obtainArtifact(ctx, p)
|
||||
if err != nil {
|
||||
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("download %s: %w", p.Name, err))
|
||||
}
|
||||
dir := filepath.Join(staging, fmt.Sprintf("%03d-%s", i, p.Name))
|
||||
if err := bundle.ExtractZip(zipPath, dir, bundle.ExtractLimits{}); err != nil {
|
||||
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("extract %s: %w", p.Name, err))
|
||||
}
|
||||
importRoot, err := bundle.FindImportRoot(dir)
|
||||
if err != nil {
|
||||
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("locate GPO root for %s: %w", p.Name, err))
|
||||
}
|
||||
policyDirs = append(policyDirs, importRoot)
|
||||
}
|
||||
|
||||
rollbackRoot := filepath.Join(r.cfg.StateDir, "rollback", time.Now().UTC().Format("20060102-150405"))
|
||||
if err := os.MkdirAll(rollbackRoot, 0o700); err != nil {
|
||||
return r.failAndReport(ctx, manifest.Generation, err)
|
||||
}
|
||||
if err := backupLocalPolicy(ctx, r.cfg.LGPOPath, rollbackRoot); err != nil {
|
||||
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("pre-apply backup failed: %w", err))
|
||||
}
|
||||
|
||||
if err := applyPolicyDirectories(ctx, r.cfg.LGPOPath, policyDirs); err != nil {
|
||||
rollbackErr := restoreLocalPolicy(ctx, r.cfg.LGPOPath, rollbackRoot)
|
||||
if rollbackErr != nil {
|
||||
err = fmt.Errorf("apply failed: %v; rollback also failed: %w", err, rollbackErr)
|
||||
} else {
|
||||
err = fmt.Errorf("apply failed and rollback succeeded: %w", err)
|
||||
}
|
||||
return r.failAndReport(ctx, manifest.Generation, err)
|
||||
}
|
||||
|
||||
newState := State{Generation: manifest.Generation, AppliedAt: time.Now().UTC(), Policies: manifest.Policies}
|
||||
if err := r.saveState(newState); err != nil {
|
||||
return r.failAndReport(ctx, manifest.Generation, fmt.Errorf("save state: %w", err))
|
||||
}
|
||||
r.pruneRollbacks(filepath.Join(r.cfg.StateDir, "rollback"), 5)
|
||||
r.report(ctx, model.ClientReport{ClientID: r.clientID, Hostname: r.hostname, Profile: r.cfg.Profile, Generation: manifest.Generation, Success: true, Message: "policy profile applied", AgentVersion: r.version, AppliedAt: newState.AppliedAt, OperatingSystem: runtime.GOOS})
|
||||
r.logger.Printf("profile=%s generation=%s policies=%d status=applied", r.cfg.Profile, manifest.Generation, len(manifest.Policies))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) fetchManifest(ctx context.Context, current string) (model.Manifest, bool, error) {
|
||||
base, _ := url.Parse(strings.TrimRight(r.cfg.ServerURL, "/") + "/")
|
||||
rel, _ := url.Parse("api/v1/profiles/" + url.PathEscape(r.cfg.Profile) + "/manifest")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base.ResolveReference(rel).String(), nil)
|
||||
if err != nil {
|
||||
return model.Manifest{}, false, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.cfg.ClientToken)
|
||||
if current != "" {
|
||||
req.Header.Set("If-None-Match", `"`+current+`"`)
|
||||
}
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return model.Manifest{}, false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return model.Manifest{}, false, nil
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return model.Manifest{}, false, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return model.Manifest{}, false, fmt.Errorf("manifest HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if err := verifySignature(body, resp.Header.Get("X-GPO-Signature"), r.cfg.SigningKey); err != nil {
|
||||
return model.Manifest{}, false, err
|
||||
}
|
||||
var manifest model.Manifest
|
||||
if err := json.Unmarshal(body, &manifest); err != nil {
|
||||
return model.Manifest{}, false, err
|
||||
}
|
||||
if manifest.Profile != r.cfg.Profile || manifest.Generation == "" {
|
||||
return model.Manifest{}, false, errors.New("invalid manifest identity")
|
||||
}
|
||||
if manifest.Generation == current {
|
||||
return manifest, false, nil
|
||||
}
|
||||
return manifest, true, nil
|
||||
}
|
||||
|
||||
func verifySignature(body []byte, header, key string) error {
|
||||
const prefix = "hmac-sha256="
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return errors.New("manifest signature missing")
|
||||
}
|
||||
got, err := hex.DecodeString(strings.TrimPrefix(header, prefix))
|
||||
if err != nil {
|
||||
return errors.New("invalid manifest signature encoding")
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
_, _ = mac.Write(body)
|
||||
if !hmac.Equal(got, mac.Sum(nil)) {
|
||||
return errors.New("manifest signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) obtainArtifact(ctx context.Context, p model.ResolvedPolicy) (string, error) {
|
||||
if len(p.SHA256) != 64 || p.Size <= 0 {
|
||||
return "", errors.New("invalid artifact metadata")
|
||||
}
|
||||
cacheDir := filepath.Join(r.cfg.StateDir, "cache")
|
||||
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
final := filepath.Join(cacheDir, p.SHA256+".zip")
|
||||
if ok, _ := verifyFile(final, p.SHA256, p.Size); ok {
|
||||
return final, nil
|
||||
}
|
||||
|
||||
base, _ := url.Parse(strings.TrimRight(r.cfg.ServerURL, "/") + "/")
|
||||
rel, err := url.Parse(strings.TrimLeft(p.DownloadURL, "/"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := base.ResolveReference(rel)
|
||||
if u.Host != base.Host || u.Scheme != base.Scheme {
|
||||
return "", errors.New("artifact URL points to a different origin")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.cfg.ClientToken)
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(cacheDir, ".download-*.zip")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
h := sha256.New()
|
||||
n, copyErr := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(resp.Body, p.Size+1))
|
||||
closeErr := tmp.Close()
|
||||
if copyErr != nil {
|
||||
return "", copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return "", closeErr
|
||||
}
|
||||
if n != p.Size {
|
||||
return "", fmt.Errorf("artifact size mismatch: expected %d, got %d", p.Size, n)
|
||||
}
|
||||
if got := hex.EncodeToString(h.Sum(nil)); got != p.SHA256 {
|
||||
return "", fmt.Errorf("artifact hash mismatch: expected %s, got %s", p.SHA256, got)
|
||||
}
|
||||
if err := os.Rename(tmpName, final); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return final, nil
|
||||
}
|
||||
|
||||
func verifyFile(filename, expectedHash string, expectedSize int64) (bool, error) {
|
||||
f, err := os.Open(filename)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if st.Size() != expectedSize {
|
||||
return false, nil
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)) == expectedHash, nil
|
||||
}
|
||||
|
||||
func (r *Runner) loadState() (State, error) {
|
||||
b, err := os.ReadFile(r.stateFile)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return State{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
var st State
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (r *Runner) saveState(st State) error {
|
||||
b, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := r.stateFile + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, r.stateFile)
|
||||
}
|
||||
|
||||
func acquireLock(filename string, staleAfter time.Duration) (func(), error) {
|
||||
f, err := os.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_, _ = fmt.Fprintf(f, "%d\n", os.Getpid())
|
||||
_ = f.Close()
|
||||
return func() { _ = os.Remove(filename) }, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return nil, err
|
||||
}
|
||||
st, statErr := os.Stat(filename)
|
||||
if statErr == nil && time.Since(st.ModTime()) > staleAfter {
|
||||
if removeErr := os.Remove(filename); removeErr == nil {
|
||||
return acquireLock(filename, staleAfter)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("another agent instance is running")
|
||||
}
|
||||
|
||||
func (r *Runner) failAndReport(ctx context.Context, generation string, err error) error {
|
||||
r.report(ctx, model.ClientReport{ClientID: r.clientID, Hostname: r.hostname, Profile: r.cfg.Profile, Generation: generation, Success: false, Message: err.Error(), AgentVersion: r.version, OperatingSystem: runtime.GOOS})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Runner) report(ctx context.Context, report model.ClientReport) {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
base := strings.TrimRight(r.cfg.ServerURL, "/") + "/api/v1/client/report"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.cfg.ClientToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
r.logger.Printf("report failed: %v", err)
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func (r *Runner) pruneRollbacks(root string, keep int) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var dirs []os.DirEntry
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
dirs = append(dirs, e)
|
||||
}
|
||||
}
|
||||
if len(dirs) <= keep {
|
||||
return
|
||||
}
|
||||
// Names are UTC timestamps, so lexical order is chronological.
|
||||
for i := 0; i < len(dirs)-keep; i++ {
|
||||
_ = os.RemoveAll(filepath.Join(root, dirs[i].Name()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigAcceptsUTF8BOM(t *testing.T) {
|
||||
filename := filepath.Join(t.TempDir(), "agent.json")
|
||||
data := append([]byte{0xEF, 0xBB, 0xBF}, []byte(`{
|
||||
"server_url":"http://127.0.0.1:8080",
|
||||
"profile":"servers",
|
||||
"client_token":"token",
|
||||
"signing_key":"key",
|
||||
"lgpo_path":"C:\\LGPO.exe",
|
||||
"state_dir":"C:\\ProgramData\\GPO-Distributor",
|
||||
"poll_interval":"15m",
|
||||
"insecure_skip_verify":true
|
||||
}`)...)
|
||||
if err := os.WriteFile(filename, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadConfig(filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Profile != "servers" {
|
||||
t.Fatalf("unexpected profile: %s", cfg.Profile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var errWindowsOnly = errors.New("policy application is supported only on Windows")
|
||||
|
||||
func backupLocalPolicy(context.Context, string, string) error { return errWindowsOnly }
|
||||
func applyPolicyDirectories(context.Context, string, []string) error { return errWindowsOnly }
|
||||
func restoreLocalPolicy(context.Context, string, string) error { return errWindowsOnly }
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func backupLocalPolicy(ctx context.Context, lgpoPath, destination string) error {
|
||||
if _, err := os.Stat(lgpoPath); err != nil {
|
||||
return fmt.Errorf("LGPO.exe not found: %w", err)
|
||||
}
|
||||
return runCommand(ctx, lgpoPath, "/q", "/b", destination, "/n", "GPO Distributor pre-apply rollback")
|
||||
}
|
||||
|
||||
func applyPolicyDirectories(ctx context.Context, lgpoPath string, directories []string) error {
|
||||
for _, dir := range directories {
|
||||
if err := runCommand(ctx, lgpoPath, "/q", "/g", dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runCommand(ctx, filepath.Join(os.Getenv("SystemRoot"), "System32", "gpupdate.exe"), "/force", "/wait:600")
|
||||
}
|
||||
|
||||
func restoreLocalPolicy(ctx context.Context, lgpoPath, rollbackRoot string) error {
|
||||
if err := runCommand(ctx, lgpoPath, "/q", "/g", rollbackRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
return runCommand(ctx, filepath.Join(os.Getenv("SystemRoot"), "System32", "gpupdate.exe"), "/force", "/wait:600")
|
||||
}
|
||||
|
||||
func runCommand(ctx context.Context, program string, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, program, args...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s %s failed: %w: %s", program, strings.Join(args, " "), err, strings.TrimSpace(out.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultMaxFiles = 20000
|
||||
DefaultMaxExpanded = int64(2 << 30) // 2 GiB
|
||||
DefaultMaxSingleFile = int64(512 << 20)
|
||||
)
|
||||
|
||||
type Inspection struct {
|
||||
ArtifactHash string
|
||||
SemanticHash string
|
||||
Size int64
|
||||
FileCount int
|
||||
PolicyFiles int
|
||||
}
|
||||
|
||||
type fileDigest struct {
|
||||
name string
|
||||
size uint64
|
||||
hash [sha256.Size]byte
|
||||
}
|
||||
|
||||
func InspectZip(filename string) (Inspection, error) {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
artifact := sha256.New()
|
||||
if _, err := io.Copy(artifact, f); err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
|
||||
zr, err := zip.OpenReader(filename)
|
||||
if err != nil {
|
||||
return Inspection{}, fmt.Errorf("invalid ZIP: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
if len(zr.File) == 0 {
|
||||
return Inspection{}, errors.New("ZIP is empty")
|
||||
}
|
||||
if len(zr.File) > DefaultMaxFiles {
|
||||
return Inspection{}, fmt.Errorf("ZIP has too many entries: %d", len(zr.File))
|
||||
}
|
||||
|
||||
var total uint64
|
||||
var payload []fileDigest
|
||||
backupXML := 0
|
||||
fileCount := 0
|
||||
|
||||
for _, zf := range zr.File {
|
||||
name, err := cleanArchivePath(zf.Name)
|
||||
if err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
if zf.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if !zf.Mode().IsRegular() {
|
||||
return Inspection{}, fmt.Errorf("unsupported ZIP entry type: %q", zf.Name)
|
||||
}
|
||||
if zf.UncompressedSize64 > uint64(DefaultMaxSingleFile) {
|
||||
return Inspection{}, fmt.Errorf("ZIP entry too large: %q", zf.Name)
|
||||
}
|
||||
total += zf.UncompressedSize64
|
||||
if total > uint64(DefaultMaxExpanded) {
|
||||
return Inspection{}, errors.New("expanded ZIP exceeds safety limit")
|
||||
}
|
||||
fileCount++
|
||||
|
||||
lower := strings.ToLower(name)
|
||||
if path.Base(lower) == "backup.xml" {
|
||||
backupXML++
|
||||
}
|
||||
if !isPolicyPayload(lower) {
|
||||
continue
|
||||
}
|
||||
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
h := sha256.New()
|
||||
_, copyErr := io.Copy(h, io.LimitReader(rc, DefaultMaxSingleFile+1))
|
||||
closeErr := rc.Close()
|
||||
if copyErr != nil {
|
||||
return Inspection{}, copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return Inspection{}, closeErr
|
||||
}
|
||||
var sum [sha256.Size]byte
|
||||
copy(sum[:], h.Sum(nil))
|
||||
payload = append(payload, fileDigest{name: lower, size: zf.UncompressedSize64, hash: sum})
|
||||
}
|
||||
|
||||
if backupXML == 0 {
|
||||
return Inspection{}, errors.New("no backup.xml found; expected a Microsoft GPO backup")
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return Inspection{}, errors.New("no policy payload under DomainSysvol/GPO found")
|
||||
}
|
||||
|
||||
sort.Slice(payload, func(i, j int) bool { return payload[i].name < payload[j].name })
|
||||
semantic := sha256.New()
|
||||
for _, item := range payload {
|
||||
writeField(semantic, []byte(item.name))
|
||||
var size [8]byte
|
||||
binary.BigEndian.PutUint64(size[:], item.size)
|
||||
writeField(semantic, size[:])
|
||||
writeField(semantic, item.hash[:])
|
||||
}
|
||||
|
||||
return Inspection{
|
||||
ArtifactHash: hex.EncodeToString(artifact.Sum(nil)),
|
||||
SemanticHash: hex.EncodeToString(semantic.Sum(nil)),
|
||||
Size: st.Size(),
|
||||
FileCount: fileCount,
|
||||
PolicyFiles: len(payload),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeField(h hash.Hash, b []byte) {
|
||||
var n [8]byte
|
||||
binary.BigEndian.PutUint64(n[:], uint64(len(b)))
|
||||
_, _ = h.Write(n[:])
|
||||
_, _ = h.Write(b)
|
||||
}
|
||||
|
||||
func isPolicyPayload(lower string) bool {
|
||||
return strings.Contains("/"+lower, "/domainsysvol/gpo/")
|
||||
}
|
||||
|
||||
func cleanArchivePath(name string) (string, error) {
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
if strings.ContainsRune(name, '\x00') {
|
||||
return "", errors.New("ZIP path contains NUL")
|
||||
}
|
||||
clean := path.Clean(name)
|
||||
if clean == "." || clean == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(clean, "/") || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, ":") {
|
||||
return "", fmt.Errorf("unsafe ZIP path: %q", name)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
type ExtractLimits struct {
|
||||
MaxFiles int
|
||||
MaxExpanded int64
|
||||
MaxSingleFile int64
|
||||
}
|
||||
|
||||
func (l ExtractLimits) withDefaults() ExtractLimits {
|
||||
if l.MaxFiles <= 0 {
|
||||
l.MaxFiles = DefaultMaxFiles
|
||||
}
|
||||
if l.MaxExpanded <= 0 {
|
||||
l.MaxExpanded = DefaultMaxExpanded
|
||||
}
|
||||
if l.MaxSingleFile <= 0 {
|
||||
l.MaxSingleFile = DefaultMaxSingleFile
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func ExtractZip(filename, destination string, limits ExtractLimits) error {
|
||||
limits = limits.withDefaults()
|
||||
zr, err := zip.OpenReader(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
if len(zr.File) > limits.MaxFiles {
|
||||
return fmt.Errorf("ZIP has too many entries: %d", len(zr.File))
|
||||
}
|
||||
if err := os.MkdirAll(destination, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := filepath.Abs(destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var total int64
|
||||
for _, zf := range zr.File {
|
||||
name, err := cleanArchivePath(zf.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if zf.UncompressedSize64 > uint64(limits.MaxSingleFile) {
|
||||
return fmt.Errorf("ZIP entry too large: %q", zf.Name)
|
||||
}
|
||||
total += int64(zf.UncompressedSize64)
|
||||
if total > limits.MaxExpanded {
|
||||
return errors.New("expanded ZIP exceeds safety limit")
|
||||
}
|
||||
if !zf.FileInfo().IsDir() && !zf.Mode().IsRegular() {
|
||||
return fmt.Errorf("unsupported ZIP entry type: %q", zf.Name)
|
||||
}
|
||||
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
absTarget, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absTarget != root && !strings.HasPrefix(absTarget, root+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("ZIP path escapes destination: %q", zf.Name)
|
||||
}
|
||||
if zf.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(absTarget, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absTarget), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(absTarget, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, io.LimitReader(rc, limits.MaxSingleFile+1))
|
||||
closeOutErr := out.Close()
|
||||
closeInErr := rc.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeOutErr != nil {
|
||||
return closeOutErr
|
||||
}
|
||||
if closeInErr != nil {
|
||||
return closeInErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindImportRoot locates the directory that should be passed to LGPO.exe /g.
|
||||
// It accepts both a normal GPMC/Backup-GPO archive root and archives wrapped
|
||||
// in one additional directory by common ZIP tools.
|
||||
func FindImportRoot(extractedRoot string) (string, error) {
|
||||
root, err := filepath.Abs(extractedRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
type candidate struct {
|
||||
path string
|
||||
depth int
|
||||
}
|
||||
var candidates []candidate
|
||||
err = filepath.WalkDir(root, func(current string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(root, current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
depth := 0
|
||||
if rel != "." {
|
||||
depth = len(strings.Split(filepath.ToSlash(rel), "/"))
|
||||
}
|
||||
if entry.IsDir() && depth > 4 {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(entry.Name(), "manifest.xml") {
|
||||
candidates = append(candidates, candidate{path: filepath.Dir(current), depth: depth})
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(entry.Name(), "backup.xml") {
|
||||
backupDir := filepath.Dir(current)
|
||||
candidates = append(candidates, candidate{path: backupDir, depth: depth})
|
||||
if backupDir != root {
|
||||
parent := filepath.Dir(backupDir)
|
||||
if parent == root || strings.HasPrefix(parent, root+string(os.PathSeparator)) {
|
||||
candidates = append(candidates, candidate{path: parent, depth: depth - 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return "", errors.New("no GPO backup root found after extraction")
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].depth == candidates[j].depth {
|
||||
return candidates[i].path < candidates[j].path
|
||||
}
|
||||
return candidates[i].depth < candidates[j].depth
|
||||
})
|
||||
for _, c := range candidates {
|
||||
if hasBackupAtOrBelow(c.path) {
|
||||
return c.path, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("no usable GPO backup root found")
|
||||
}
|
||||
|
||||
func hasBackupAtOrBelow(dir string) bool {
|
||||
if _, err := os.Stat(filepath.Join(dir, "backup.xml")); err == nil {
|
||||
return true
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, entry.Name(), "backup.xml")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func createZip(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
name := filepath.Join(t.TempDir(), "bundle.zip")
|
||||
f, err := os.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
for path, content := range files {
|
||||
w, err := zw.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func TestSemanticHashIgnoresBackupMetadata(t *testing.T) {
|
||||
base := map[string]string{
|
||||
"manifest.xml": "time=one",
|
||||
"{A}/backup.xml": "version=1",
|
||||
"{A}/bkupInfo.xml": "time=one",
|
||||
"{A}/DomainSysvol/GPO/Machine/registry.pol": "policy",
|
||||
}
|
||||
a := createZip(t, base)
|
||||
base["manifest.xml"] = "time=two"
|
||||
base["{A}/bkupInfo.xml"] = "time=two"
|
||||
base["{A}/backup.xml"] = "version=2"
|
||||
b := createZip(t, base)
|
||||
ia, err := InspectZip(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ib, err := InspectZip(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ia.SemanticHash != ib.SemanticHash {
|
||||
t.Fatalf("semantic hashes differ: %s != %s", ia.SemanticHash, ib.SemanticHash)
|
||||
}
|
||||
if ia.ArtifactHash == ib.ArtifactHash {
|
||||
t.Fatal("artifact hashes should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticHashChangesWithPolicy(t *testing.T) {
|
||||
a := createZip(t, map[string]string{
|
||||
"{A}/backup.xml": "x",
|
||||
"{A}/DomainSysvol/GPO/Machine/registry.pol": "one",
|
||||
})
|
||||
b := createZip(t, map[string]string{
|
||||
"{A}/backup.xml": "x",
|
||||
"{A}/DomainSysvol/GPO/Machine/registry.pol": "two",
|
||||
})
|
||||
ia, _ := InspectZip(a)
|
||||
ib, _ := InspectZip(b)
|
||||
if ia.SemanticHash == ib.SemanticHash {
|
||||
t.Fatal("semantic hashes should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsTraversal(t *testing.T) {
|
||||
z := createZip(t, map[string]string{"../evil": "x"})
|
||||
if err := ExtractZip(z, t.TempDir(), ExtractLimits{}); err == nil {
|
||||
t.Fatal("expected traversal error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindImportRootWrappedArchive(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
backupRoot := filepath.Join(root, "wrapper", "backup")
|
||||
if err := os.MkdirAll(filepath.Join(backupRoot, "{A}"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupRoot, "manifest.xml"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupRoot, "{A}", "backup.xml"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := FindImportRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != backupRoot {
|
||||
t.Fatalf("got %s, want %s", got, backupRoot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gpo-distributor/internal/bundle"
|
||||
"gpo-distributor/internal/model"
|
||||
"gpo-distributor/internal/store"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AdminToken string
|
||||
ClientToken string
|
||||
SigningKey string
|
||||
ServerVersion string
|
||||
MaxUpload int64
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
cfg Config
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func New(s *store.Store, cfg Config) (*Server, error) {
|
||||
if cfg.AdminToken == "" || cfg.ClientToken == "" || cfg.SigningKey == "" {
|
||||
return nil, errors.New("admin token, client token and signing key are required")
|
||||
}
|
||||
if cfg.MaxUpload <= 0 {
|
||||
cfg.MaxUpload = 512 << 20
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = log.Default()
|
||||
}
|
||||
api := &Server{store: s, cfg: cfg, mux: http.NewServeMux()}
|
||||
api.routes()
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.logRequests(s.securityHeaders(s.mux))
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.registerWebUI()
|
||||
|
||||
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "version": s.cfg.ServerVersion})
|
||||
})
|
||||
|
||||
s.mux.Handle("POST /api/v1/admin/policies/{name}/versions", s.requireAdmin(http.HandlerFunc(s.uploadPolicy)))
|
||||
s.mux.Handle("GET /api/v1/admin/policies", s.requireAdmin(http.HandlerFunc(s.listPolicies)))
|
||||
s.mux.Handle("GET /api/v1/admin/policies/{name}", s.requireAdmin(http.HandlerFunc(s.getPolicy)))
|
||||
s.mux.Handle("DELETE /api/v1/admin/policies/{name}", s.requireAdmin(http.HandlerFunc(s.deletePolicy)))
|
||||
s.mux.Handle("DELETE /api/v1/admin/policies/{name}/versions/{version}", s.requireAdmin(http.HandlerFunc(s.deletePolicyVersion)))
|
||||
s.mux.Handle("GET /api/v1/admin/policies/{policy}/versions/{version}/artifact", s.requireAdmin(http.HandlerFunc(s.getArtifact)))
|
||||
|
||||
s.mux.Handle("PUT /api/v1/admin/profiles/{name}", s.requireAdmin(http.HandlerFunc(s.setProfile)))
|
||||
s.mux.Handle("DELETE /api/v1/admin/profiles/{name}", s.requireAdmin(http.HandlerFunc(s.deleteProfile)))
|
||||
s.mux.Handle("GET /api/v1/admin/profiles", s.requireAdmin(http.HandlerFunc(s.listProfiles)))
|
||||
|
||||
s.mux.Handle("GET /api/v1/admin/clients", s.requireAdmin(http.HandlerFunc(s.listClients)))
|
||||
s.mux.Handle("DELETE /api/v1/admin/clients/{id}", s.requireAdmin(http.HandlerFunc(s.deleteClient)))
|
||||
|
||||
s.mux.Handle("GET /api/v1/profiles/{name}/manifest", s.requireToken(s.cfg.ClientToken, http.HandlerFunc(s.getManifest)))
|
||||
s.mux.Handle("GET /api/v1/artifacts/{policy}/{version}", s.requireToken(s.cfg.ClientToken, http.HandlerFunc(s.getArtifact)))
|
||||
s.mux.Handle("POST /api/v1/client/report", s.requireToken(s.cfg.ClientToken, http.HandlerFunc(s.clientReport)))
|
||||
}
|
||||
|
||||
func (s *Server) uploadPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if err := store.ValidateName(name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUpload)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("multipart/form-data required: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "gpo-upload-*.zip")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
defer tmp.Close()
|
||||
|
||||
note := ""
|
||||
force := false
|
||||
found := false
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
switch part.FormName() {
|
||||
case "bundle":
|
||||
if found {
|
||||
part.Close()
|
||||
writeError(w, http.StatusBadRequest, errors.New("only one bundle is allowed"))
|
||||
return
|
||||
}
|
||||
found = true
|
||||
if _, err := io.Copy(tmp, io.LimitReader(part, s.cfg.MaxUpload+1)); err != nil {
|
||||
part.Close()
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
case "note":
|
||||
b, err := io.ReadAll(io.LimitReader(part, 4097))
|
||||
if err != nil {
|
||||
part.Close()
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if len(b) > 4096 {
|
||||
part.Close()
|
||||
writeError(w, http.StatusBadRequest, errors.New("note too long"))
|
||||
return
|
||||
}
|
||||
note = string(b)
|
||||
case "force":
|
||||
b, err := io.ReadAll(io.LimitReader(part, 16))
|
||||
if err != nil {
|
||||
part.Close()
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
force = strings.EqualFold(strings.TrimSpace(string(b)), "true")
|
||||
}
|
||||
part.Close()
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusBadRequest, errors.New("multipart field 'bundle' is required"))
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
inspection, err := bundle.InspectZip(tmpName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
version, created, err := s.store.ImportPolicy(name, note, tmpName, inspection, time.Now(), force)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if !created {
|
||||
status = http.StatusOK
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"created": created, "version": version})
|
||||
}
|
||||
|
||||
func (s *Server) listPolicies(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.store.ListPolicies())
|
||||
}
|
||||
func (s *Server) getPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.store.GetPolicy(r.PathValue("name"))
|
||||
if err != nil {
|
||||
handleStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) deletePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if err := store.ValidateName(name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeletePolicy(name); err != nil {
|
||||
handleStoreError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) deletePolicyVersion(w http.ResponseWriter, r *http.Request) {
|
||||
name, version := r.PathValue("name"), r.PathValue("version")
|
||||
if err := store.ValidateName(name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := store.ValidateName(version); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeletePolicyVersion(name, version); err != nil {
|
||||
handleStoreError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) setProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Policies []model.ProfilePolicy `json:"policies"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &body, 1<<20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
p, err := s.store.SetProfile(r.PathValue("name"), body.Policies, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
} else {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
func (s *Server) listProfiles(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.store.ListProfiles())
|
||||
}
|
||||
func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if err := store.ValidateName(name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteProfile(name); err != nil {
|
||||
handleStoreError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func (s *Server) listClients(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.store.ListClientReports())
|
||||
}
|
||||
func (s *Server) deleteClient(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteClientReport(r.PathValue("id")); err != nil {
|
||||
handleStoreError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) getManifest(w http.ResponseWriter, r *http.Request) {
|
||||
manifest, err := s.store.ResolveManifest(r.PathValue("name"))
|
||||
if err != nil {
|
||||
handleStoreError(w, err)
|
||||
return
|
||||
}
|
||||
etag := `"` + manifest.Generation + `"`
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(s.cfg.SigningKey))
|
||||
_, _ = mac.Write(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("X-GPO-Signature", "hmac-sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
policy, version := r.PathValue("policy"), r.PathValue("version")
|
||||
if err := store.ValidateName(policy); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := store.ValidateName(version); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
meta, filename, err := s.store.Artifact(policy, version)
|
||||
if err != nil {
|
||||
handleStoreError(w, err)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filepath.Base(filename)))
|
||||
w.Header().Set("ETag", `"`+meta.ArtifactHash+`"`)
|
||||
w.Header().Set("X-Content-SHA256", meta.ArtifactHash)
|
||||
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
http.ServeContent(w, r, filepath.Base(filename), st.ModTime(), f)
|
||||
}
|
||||
|
||||
func (s *Server) clientReport(w http.ResponseWriter, r *http.Request) {
|
||||
var report model.ClientReport
|
||||
if err := decodeJSON(w, r, &report, 1<<20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
report.ReportedAt = time.Now().UTC()
|
||||
if err := s.store.PutClientReport(report); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) requireToken(expected string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeError(w, http.StatusUnauthorized, errors.New("missing bearer token"))
|
||||
return
|
||||
}
|
||||
provided := strings.TrimPrefix(auth, "Bearer ")
|
||||
if len(provided) != len(expected) || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
|
||||
writeError(w, http.StatusForbidden, errors.New("invalid bearer token"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
s.cfg.Logger.Printf("method=%s path=%s remote=%s duration=%s", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any, limit int64) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return errors.New("request body must contain one JSON value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
func handleStoreError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
writeError(w, http.StatusConflict, err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
"use strict";
|
||||
|
||||
const state = {
|
||||
csrf: "",
|
||||
version: "dev",
|
||||
policies: [],
|
||||
profiles: [],
|
||||
clients: [],
|
||||
route: "dashboard",
|
||||
policyFilter: "",
|
||||
clientFilter: "",
|
||||
clientStatus: "all",
|
||||
clientProfile: "all",
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const refs = {
|
||||
loginView: document.querySelector("#login-view"),
|
||||
appView: document.querySelector("#app-view"),
|
||||
loginForm: document.querySelector("#login-form"),
|
||||
loginError: document.querySelector("#login-error"),
|
||||
tokenInput: document.querySelector("#admin-token"),
|
||||
toggleToken: document.querySelector("#toggle-token"),
|
||||
logout: document.querySelector("#logout-button"),
|
||||
pageContent: document.querySelector("#page-content"),
|
||||
pageTitle: document.querySelector("#page-title"),
|
||||
pageEyebrow: document.querySelector("#page-eyebrow"),
|
||||
primaryAction: document.querySelector("#primary-action"),
|
||||
refresh: document.querySelector("#refresh-button"),
|
||||
lastRefresh: document.querySelector("#last-refresh"),
|
||||
serverVersion: document.querySelector("#server-version"),
|
||||
navPolicyCount: document.querySelector("#nav-policy-count"),
|
||||
navProfileCount: document.querySelector("#nav-profile-count"),
|
||||
navClientCount: document.querySelector("#nav-client-count"),
|
||||
modalBackdrop: document.querySelector("#modal-backdrop"),
|
||||
modal: document.querySelector("#modal"),
|
||||
modalTitle: document.querySelector("#modal-title"),
|
||||
modalEyebrow: document.querySelector("#modal-eyebrow"),
|
||||
modalContent: document.querySelector("#modal-content"),
|
||||
modalClose: document.querySelector("#modal-close"),
|
||||
confirmBackdrop: document.querySelector("#confirm-backdrop"),
|
||||
confirmTitle: document.querySelector("#confirm-title"),
|
||||
confirmMessage: document.querySelector("#confirm-message"),
|
||||
confirmSubmit: document.querySelector("#confirm-submit"),
|
||||
confirmCancel: document.querySelector("#confirm-cancel"),
|
||||
toastRegion: document.querySelector("#toast-region"),
|
||||
};
|
||||
|
||||
const routeMeta = {
|
||||
dashboard: { title: "Übersicht", eyebrow: "Verwaltung", action: "" },
|
||||
policies: { title: "Richtlinien", eyebrow: "Policy Repository", action: "Richtlinie hochladen" },
|
||||
profiles: { title: "Profile", eyebrow: "Zuweisungen", action: "Profil erstellen" },
|
||||
clients: { title: "Clients", eyebrow: "Agent-Status", action: "" },
|
||||
};
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function encoded(value) {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
function decoded(value) {
|
||||
return decodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
function formatDate(value, withSeconds = false) {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: withSeconds ? "medium" : "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function relativeDate(value) {
|
||||
if (!value) return "nie";
|
||||
const date = new Date(value);
|
||||
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
|
||||
const abs = Math.abs(seconds);
|
||||
const formatter = new Intl.RelativeTimeFormat("de-DE", { numeric: "auto" });
|
||||
if (abs < 60) return formatter.format(seconds, "second");
|
||||
if (abs < 3600) return formatter.format(Math.round(seconds / 60), "minute");
|
||||
if (abs < 86400) return formatter.format(Math.round(seconds / 3600), "hour");
|
||||
return formatter.format(Math.round(seconds / 86400), "day");
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (value < 1024) return `${value} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let size = value;
|
||||
let unit = -1;
|
||||
do {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
} while (size >= 1024 && unit < units.length - 1);
|
||||
return `${size.toLocaleString("de-DE", { maximumFractionDigits: size >= 10 ? 1 : 2 })} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function shortHash(value, length = 12) {
|
||||
if (!value) return "—";
|
||||
return `${String(value).slice(0, length)}…`;
|
||||
}
|
||||
|
||||
function isStale(client) {
|
||||
if (!client.reported_at) return true;
|
||||
return Date.now() - new Date(client.reported_at).getTime() > 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(method) && state.csrf) {
|
||||
headers.set("X-CSRF-Token", state.csrf);
|
||||
}
|
||||
if (options.json !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
options.body = JSON.stringify(options.json);
|
||||
delete options.json;
|
||||
}
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
...options,
|
||||
method,
|
||||
headers,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
showLogin();
|
||||
throw new Error("Die Sitzung ist abgelaufen. Bitte erneut anmelden.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
message = body.error || message;
|
||||
} catch (_) {
|
||||
// Keep the HTTP status as fallback.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
const type = response.headers.get("Content-Type") || "";
|
||||
return type.includes("application/json") ? response.json() : response;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
bindStaticEvents();
|
||||
try {
|
||||
const session = await request("/ui/api/session");
|
||||
state.csrf = session.csrf_token;
|
||||
state.version = session.version || "dev";
|
||||
showApp();
|
||||
await loadAll();
|
||||
} catch (_) {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
function bindStaticEvents() {
|
||||
refs.loginForm.addEventListener("submit", login);
|
||||
refs.toggleToken.addEventListener("click", () => {
|
||||
const visible = refs.tokenInput.type === "text";
|
||||
refs.tokenInput.type = visible ? "password" : "text";
|
||||
refs.toggleToken.textContent = visible ? "Anzeigen" : "Verbergen";
|
||||
});
|
||||
refs.logout.addEventListener("click", logout);
|
||||
refs.refresh.addEventListener("click", () => loadAll(true));
|
||||
refs.primaryAction.addEventListener("click", () => {
|
||||
if (state.route === "policies") openUploadDialog();
|
||||
if (state.route === "profiles") openProfileEditor(null);
|
||||
});
|
||||
document.querySelectorAll(".nav-item").forEach((button) => {
|
||||
button.addEventListener("click", () => navigate(button.dataset.route));
|
||||
});
|
||||
refs.pageContent.addEventListener("click", handlePageClick);
|
||||
refs.pageContent.addEventListener("input", handlePageInput);
|
||||
refs.pageContent.addEventListener("change", handlePageInput);
|
||||
refs.modalClose.addEventListener("click", closeModal);
|
||||
refs.modalBackdrop.addEventListener("click", (event) => {
|
||||
if (event.target === refs.modalBackdrop) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && !refs.modalBackdrop.classList.contains("hidden")) closeModal();
|
||||
});
|
||||
window.addEventListener("hashchange", () => {
|
||||
const route = location.hash.replace(/^#\/?/, "");
|
||||
if (routeMeta[route]) setRoute(route, false);
|
||||
});
|
||||
}
|
||||
|
||||
async function login(event) {
|
||||
event.preventDefault();
|
||||
refs.loginError.textContent = "";
|
||||
const submit = refs.loginForm.querySelector("button[type='submit']");
|
||||
submit.disabled = true;
|
||||
submit.textContent = "Anmeldung läuft …";
|
||||
try {
|
||||
const session = await request("/ui/api/session", {
|
||||
method: "POST",
|
||||
json: { token: refs.tokenInput.value },
|
||||
});
|
||||
state.csrf = session.csrf_token;
|
||||
state.version = session.version || "dev";
|
||||
refs.tokenInput.value = "";
|
||||
showApp();
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
refs.loginError.textContent = error.message === "invalid credentials" ? "Das Admin-Token ist ungültig." : error.message;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = "Anmelden";
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await request("/ui/api/session", { method: "DELETE" });
|
||||
} catch (_) {
|
||||
// A local logout is still useful if the server session is already invalid.
|
||||
}
|
||||
state.csrf = "";
|
||||
state.policies = [];
|
||||
state.profiles = [];
|
||||
state.clients = [];
|
||||
showLogin();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
refs.appView.classList.add("hidden");
|
||||
refs.loginView.classList.remove("hidden");
|
||||
refs.loginError.textContent = "";
|
||||
setTimeout(() => refs.tokenInput.focus(), 0);
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
refs.loginView.classList.add("hidden");
|
||||
refs.appView.classList.remove("hidden");
|
||||
refs.serverVersion.textContent = `Backend ${state.version}`;
|
||||
const hashRoute = location.hash.replace(/^#\/?/, "");
|
||||
setRoute(routeMeta[hashRoute] ? hashRoute : "dashboard", false);
|
||||
}
|
||||
|
||||
async function loadAll(notify = false) {
|
||||
if (state.loading) return;
|
||||
state.loading = true;
|
||||
refs.refresh.disabled = true;
|
||||
refs.refresh.textContent = "Lädt …";
|
||||
if (!state.policies.length && !state.profiles.length && !state.clients.length) {
|
||||
refs.pageContent.innerHTML = loadingMarkup("Verwaltungsdaten werden geladen …");
|
||||
}
|
||||
try {
|
||||
const [policies, profiles, clients] = await Promise.all([
|
||||
request("/api/v1/admin/policies"),
|
||||
request("/api/v1/admin/profiles"),
|
||||
request("/api/v1/admin/clients"),
|
||||
]);
|
||||
state.policies = policies || [];
|
||||
state.profiles = profiles || [];
|
||||
state.clients = clients || [];
|
||||
updateCounts();
|
||||
renderRoute();
|
||||
refs.lastRefresh.textContent = `Stand ${new Intl.DateTimeFormat("de-DE", { timeStyle: "short" }).format(new Date())}`;
|
||||
if (notify) toast("success", "Aktualisiert", "Die Verwaltungsdaten sind auf dem neuesten Stand.");
|
||||
} catch (error) {
|
||||
refs.pageContent.innerHTML = errorState(error.message);
|
||||
if (notify) toast("error", "Aktualisierung fehlgeschlagen", error.message);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
refs.refresh.disabled = false;
|
||||
refs.refresh.textContent = "Aktualisieren";
|
||||
}
|
||||
}
|
||||
|
||||
function updateCounts() {
|
||||
refs.navPolicyCount.textContent = state.policies.length;
|
||||
refs.navProfileCount.textContent = state.profiles.length;
|
||||
refs.navClientCount.textContent = state.clients.length;
|
||||
}
|
||||
|
||||
function navigate(route) {
|
||||
if (!routeMeta[route]) return;
|
||||
location.hash = route;
|
||||
setRoute(route, false);
|
||||
}
|
||||
|
||||
function setRoute(route, updateHash = true) {
|
||||
state.route = route;
|
||||
if (updateHash) location.hash = route;
|
||||
document.querySelectorAll(".nav-item").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.route === route);
|
||||
});
|
||||
const meta = routeMeta[route];
|
||||
refs.pageTitle.textContent = meta.title;
|
||||
refs.pageEyebrow.textContent = meta.eyebrow;
|
||||
refs.primaryAction.textContent = meta.action;
|
||||
refs.primaryAction.classList.toggle("hidden", !meta.action);
|
||||
renderRoute();
|
||||
refs.pageContent.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function renderRoute() {
|
||||
if (refs.appView.classList.contains("hidden")) return;
|
||||
if (state.route === "dashboard") renderDashboard();
|
||||
if (state.route === "policies") renderPolicies();
|
||||
if (state.route === "profiles") renderProfiles();
|
||||
if (state.route === "clients") renderClients();
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
const versions = state.policies.reduce((sum, policy) => sum + policy.versions.length, 0);
|
||||
const healthy = state.clients.filter((client) => client.success && !isStale(client)).length;
|
||||
const failed = state.clients.filter((client) => !client.success && !isStale(client)).length;
|
||||
const stale = state.clients.filter(isStale).length;
|
||||
const healthPercent = state.clients.length ? Math.round((healthy / state.clients.length) * 100) : 0;
|
||||
const recentVersions = state.policies
|
||||
.flatMap((policy) => policy.versions.map((version) => ({ ...version, policy: policy.name })))
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
||||
.slice(0, 7);
|
||||
const recentClients = [...state.clients]
|
||||
.sort((a, b) => new Date(b.reported_at) - new Date(a.reported_at))
|
||||
.slice(0, 6);
|
||||
|
||||
refs.pageContent.innerHTML = `
|
||||
<section class="stats-grid" aria-label="Kennzahlen">
|
||||
${statCard("Richtlinienobjekte", state.policies.length, `${versions} unveränderliche Versionen`)}
|
||||
${statCard("Profile", state.profiles.length, "Geordnete Richtliniensätze")}
|
||||
${statCard("Gemeldete Clients", state.clients.length, `${healthy} aktuell erfolgreich`)}
|
||||
${statCard("Handlungsbedarf", failed + stale, `${failed} Fehler · ${stale} länger als 24 h still`)}
|
||||
</section>
|
||||
<section class="dashboard-grid">
|
||||
<div class="panel">
|
||||
<header class="panel-header"><h2>Letzte Richtlinienversionen</h2><button class="button ghost small" data-action="go-policies">Alle anzeigen</button></header>
|
||||
<div class="panel-body table-wrap">
|
||||
${recentVersions.length ? `
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Richtlinie</th><th>Version</th><th>Zeitpunkt</th><th>Größe</th></tr></thead>
|
||||
<tbody>${recentVersions.map((version) => `
|
||||
<tr>
|
||||
<td><strong>${escapeHTML(version.policy)}</strong></td>
|
||||
<td class="mono">${escapeHTML(version.version)}</td>
|
||||
<td title="${escapeHTML(formatDate(version.created_at, true))}">${escapeHTML(relativeDate(version.created_at))}</td>
|
||||
<td>${formatBytes(version.size)}</td>
|
||||
</tr>`).join("")}</tbody>
|
||||
</table>` : emptyInline("Noch keine Richtlinienversion vorhanden.")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<header class="panel-header"><h2>Client-Gesundheit</h2><button class="button ghost small" data-action="go-clients">Details</button></header>
|
||||
<div class="health-ring">
|
||||
<div class="ring-chart">
|
||||
<svg class="ring-svg" viewBox="0 0 42 42" aria-hidden="true">
|
||||
<circle class="ring-track" cx="21" cy="21" r="15.9155"></circle>
|
||||
<circle class="ring-progress" cx="21" cy="21" r="15.9155" pathLength="100" stroke-dasharray="${healthPercent} 100"></circle>
|
||||
</svg>
|
||||
<div class="ring-center"><span class="ring-value">${healthPercent}%</span><span class="ring-label">aktuell erfolgreich</span></div>
|
||||
</div>
|
||||
<div class="health-legend">
|
||||
<div class="legend-row"><span><span class="badge success">Erfolgreich</span></span><strong>${healthy}</strong></div>
|
||||
<div class="legend-row"><span><span class="badge error">Fehler</span></span><strong>${failed}</strong></div>
|
||||
<div class="legend-row"><span><span class="badge warning">Veraltet</span></span><strong>${stale}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel panel-spaced">
|
||||
<header class="panel-header"><h2>Zuletzt gemeldete Clients</h2></header>
|
||||
<div class="panel-body table-wrap">
|
||||
${recentClients.length ? clientTable(recentClients, false) : emptyInline("Noch kein Agent hat einen Status gemeldet.")}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function statCard(label, value, detail) {
|
||||
return `<article class="stat-card"><span class="stat-label">${escapeHTML(label)}</span><strong class="stat-value">${escapeHTML(value)}</strong><span class="stat-detail">${escapeHTML(detail)}</span></article>`;
|
||||
}
|
||||
|
||||
function renderPolicies() {
|
||||
refs.pageContent.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<div class="search-wrap"><input id="policy-search" type="search" placeholder="Richtlinien durchsuchen" value="${escapeHTML(state.policyFilter)}" aria-label="Richtlinien durchsuchen"></div>
|
||||
<div class="toolbar-group"><span class="muted small-text">${state.policies.length} Objekte</span></div>
|
||||
</div>
|
||||
<div id="policy-list">${policyCards()}</div>`;
|
||||
}
|
||||
|
||||
function policyCards() {
|
||||
const query = state.policyFilter.trim().toLowerCase();
|
||||
const policies = state.policies.filter((policy) => policy.name.toLowerCase().includes(query));
|
||||
if (!policies.length) {
|
||||
return state.policies.length
|
||||
? emptyState("⌕", "Keine Richtlinie gefunden", "Passe den Suchbegriff an.", "")
|
||||
: emptyState("▤", "Noch keine Richtlinie", "Lade die erste Microsoft-GPO-Sicherung als ZIP hoch.", `<button class="button primary" data-action="upload-policy">Richtlinie hochladen</button>`);
|
||||
}
|
||||
return `<div class="policy-list">${policies.map((policy) => {
|
||||
const versions = [...policy.versions].reverse();
|
||||
const latest = versions[0];
|
||||
return `<article class="policy-card">
|
||||
<div class="policy-summary">
|
||||
<div>
|
||||
<div class="policy-title-row"><h2>${escapeHTML(policy.name)}</h2><span class="badge blue">${policy.versions.length} Version${policy.versions.length === 1 ? "" : "en"}</span></div>
|
||||
<div class="policy-meta">
|
||||
<span>Aktuell: <span class="mono">${escapeHTML(latest?.version || "—")}</span></span>
|
||||
<span>Geändert: ${escapeHTML(relativeDate(latest?.created_at))}</span>
|
||||
<span>${formatBytes(latest?.size || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="policy-actions">
|
||||
<button class="button secondary small" data-action="upload-to-policy" data-policy="${encoded(policy.name)}">Neue Version</button>
|
||||
<button class="button danger-soft small" data-action="delete-policy" data-policy="${encoded(policy.name)}">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-list">
|
||||
${versions.map((version, index) => `
|
||||
<div class="version-row">
|
||||
<div>
|
||||
<div class="version-id mono">${escapeHTML(version.version)} ${index === 0 ? '<span class="badge success">latest</span>' : ""}</div>
|
||||
<div class="small-text muted" title="${escapeHTML(formatDate(version.created_at, true))}">${escapeHTML(formatDate(version.created_at))}</div>
|
||||
</div>
|
||||
<div class="version-note">${version.note ? escapeHTML(version.note) : '<span class="muted">Keine Notiz</span>'}</div>
|
||||
<div>
|
||||
<div class="hash-line"><span>Semantik</span><span class="mono truncate" title="${escapeHTML(version.semantic_sha256)}">${escapeHTML(shortHash(version.semantic_sha256, 18))}</span><button class="icon-button" data-action="copy" data-copy="${escapeHTML(version.semantic_sha256)}">Kopieren</button></div>
|
||||
<div class="hash-line"><span>${version.policy_file_count} Policy-Dateien · ${version.file_count} gesamt · ${formatBytes(version.size)}</span></div>
|
||||
</div>
|
||||
<div class="row-buttons">
|
||||
<button class="button secondary small" data-action="download-version" data-policy="${encoded(policy.name)}" data-version="${encoded(version.version)}">ZIP</button>
|
||||
<button class="button danger-soft small" data-action="delete-version" data-policy="${encoded(policy.name)}" data-version="${encoded(version.version)}" ${policy.versions.length === 1 ? "disabled title=\"Letzte Version: Richtlinie vollständig löschen\"" : ""}>Löschen</button>
|
||||
</div>
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
if (!state.profiles.length) {
|
||||
refs.pageContent.innerHTML = emptyState("◫", "Noch kein Profil", "Ein Profil definiert die Reihenfolge und Versionen, die ein Client anwenden soll.", `<button class="button primary" data-action="create-profile" ${state.policies.length ? "" : "disabled"}>Profil erstellen</button>`);
|
||||
return;
|
||||
}
|
||||
refs.pageContent.innerHTML = `
|
||||
<div class="section-heading"><div><h2>Richtlinienzuweisungen</h2><p>Die Reihenfolge bestimmt, in welcher Reihenfolge LGPO die Sicherungen importiert.</p></div></div>
|
||||
<div class="profile-grid">${state.profiles.map((profile) => `
|
||||
<article class="profile-card">
|
||||
<div class="profile-card-header">
|
||||
<div><h2>${escapeHTML(profile.name)}</h2><div class="small-text muted">Aktualisiert ${escapeHTML(relativeDate(profile.updated_at))}</div></div>
|
||||
<span class="badge neutral">${profile.policies.length} Richtlinien</span>
|
||||
</div>
|
||||
<div class="policy-stack">${profile.policies.map((ref, index) => `
|
||||
<div class="policy-stack-item"><span class="order-number">${index + 1}</span><strong>${escapeHTML(ref.policy)}</strong><span class="badge ${ref.version === "latest" ? "blue" : "neutral"}">${escapeHTML(ref.version)}</span></div>`).join("")}</div>
|
||||
<footer class="profile-card-footer">
|
||||
<span class="small-text muted">${escapeHTML(formatDate(profile.updated_at))}</span>
|
||||
<div class="toolbar-group">
|
||||
<button class="button secondary small" data-action="edit-profile" data-profile="${encoded(profile.name)}">Bearbeiten</button>
|
||||
<button class="button danger-soft small" data-action="delete-profile" data-profile="${encoded(profile.name)}">Löschen</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderClients() {
|
||||
const profiles = [...new Set(state.clients.map((client) => client.profile).filter(Boolean))].sort();
|
||||
const visible = filteredClients();
|
||||
refs.pageContent.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<div class="search-wrap"><input id="client-search" type="search" placeholder="Client, Hostname oder Meldung" value="${escapeHTML(state.clientFilter)}" aria-label="Clients durchsuchen"></div>
|
||||
<select id="client-status" class="filter-select" aria-label="Status filtern">
|
||||
<option value="all" ${state.clientStatus === "all" ? "selected" : ""}>Alle Status</option>
|
||||
<option value="success" ${state.clientStatus === "success" ? "selected" : ""}>Erfolgreich</option>
|
||||
<option value="error" ${state.clientStatus === "error" ? "selected" : ""}>Fehler</option>
|
||||
<option value="stale" ${state.clientStatus === "stale" ? "selected" : ""}>Veraltet</option>
|
||||
</select>
|
||||
<select id="client-profile" class="filter-select" aria-label="Profil filtern">
|
||||
<option value="all">Alle Profile</option>
|
||||
${profiles.map((profile) => `<option value="${escapeHTML(profile)}" ${state.clientProfile === profile ? "selected" : ""}>${escapeHTML(profile)}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<span class="muted small-text">${visible.length} von ${state.clients.length}</span>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<div id="client-table" class="panel-body table-wrap">${visible.length ? clientTable(visible, true) : emptyInline("Keine Clients entsprechen dem Filter.")}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function filteredClients() {
|
||||
const query = state.clientFilter.trim().toLowerCase();
|
||||
return state.clients.filter((client) => {
|
||||
const haystack = [client.client_id, client.hostname, client.profile, client.message, client.operating_system].join(" ").toLowerCase();
|
||||
if (query && !haystack.includes(query)) return false;
|
||||
if (state.clientProfile !== "all" && client.profile !== state.clientProfile) return false;
|
||||
if (state.clientStatus === "stale" && !isStale(client)) return false;
|
||||
if (state.clientStatus === "success" && (!client.success || isStale(client))) return false;
|
||||
if (state.clientStatus === "error" && (client.success || isStale(client))) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function clientTable(clients, includeActions) {
|
||||
return `<table class="data-table">
|
||||
<thead><tr><th>Client</th><th>Status</th><th>Profil</th><th>Generation</th><th>Letzte Meldung</th><th>System</th>${includeActions ? "<th></th>" : ""}</tr></thead>
|
||||
<tbody>${clients.map((client) => {
|
||||
const stale = isStale(client);
|
||||
const status = stale ? '<span class="badge warning">Veraltet</span>' : client.success ? '<span class="badge success">Erfolgreich</span>' : '<span class="badge error">Fehler</span>';
|
||||
return `<tr class="${stale ? "client-stale" : ""}">
|
||||
<td><div class="client-name">${escapeHTML(client.hostname || client.client_id)}</div><div class="client-id mono">${escapeHTML(client.client_id)}</div>${client.message ? `<div class="client-message small-text muted" title="${escapeHTML(client.message)}">${escapeHTML(client.message)}</div>` : ""}</td>
|
||||
<td>${status}</td>
|
||||
<td><span class="badge neutral">${escapeHTML(client.profile || "—")}</span></td>
|
||||
<td class="mono" title="${escapeHTML(client.generation || "")}">${escapeHTML(shortHash(client.generation, 10))}</td>
|
||||
<td title="${escapeHTML(formatDate(client.reported_at, true))}">${escapeHTML(relativeDate(client.reported_at))}</td>
|
||||
<td><div>${escapeHTML(client.operating_system || "—")}</div><div class="small-text muted">Agent ${escapeHTML(client.agent_version || "—")}</div></td>
|
||||
${includeActions ? `<td class="actions"><button class="button danger-soft small" data-action="delete-client" data-client="${encoded(client.client_id)}">Entfernen</button></td>` : ""}
|
||||
</tr>`;
|
||||
}).join("")}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function handlePageInput(event) {
|
||||
if (event.target.id === "policy-search") {
|
||||
state.policyFilter = event.target.value;
|
||||
const list = document.querySelector("#policy-list");
|
||||
if (list) list.innerHTML = policyCards();
|
||||
}
|
||||
if (event.target.id === "client-search") state.clientFilter = event.target.value;
|
||||
if (event.target.id === "client-status") state.clientStatus = event.target.value;
|
||||
if (event.target.id === "client-profile") state.clientProfile = event.target.value;
|
||||
if (["client-search", "client-status", "client-profile"].includes(event.target.id)) {
|
||||
const table = document.querySelector("#client-table");
|
||||
const visible = filteredClients();
|
||||
if (table) table.innerHTML = visible.length ? clientTable(visible, true) : emptyInline("Keine Clients entsprechen dem Filter.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePageClick(event) {
|
||||
const button = event.target.closest("[data-action]");
|
||||
if (!button) return;
|
||||
const action = button.dataset.action;
|
||||
if (action === "retry") await loadAll(true);
|
||||
if (action === "go-policies") navigate("policies");
|
||||
if (action === "go-clients") navigate("clients");
|
||||
if (action === "upload-policy" || action === "create-profile") {
|
||||
action === "upload-policy" ? openUploadDialog() : openProfileEditor(null);
|
||||
}
|
||||
if (action === "upload-to-policy") openUploadDialog(decoded(button.dataset.policy));
|
||||
if (action === "edit-profile") openProfileEditor(state.profiles.find((profile) => profile.name === decoded(button.dataset.profile)));
|
||||
if (action === "delete-policy") await deletePolicy(decoded(button.dataset.policy));
|
||||
if (action === "delete-version") await deleteVersion(decoded(button.dataset.policy), decoded(button.dataset.version));
|
||||
if (action === "download-version") await downloadVersion(decoded(button.dataset.policy), decoded(button.dataset.version), button);
|
||||
if (action === "delete-profile") await deleteProfile(decoded(button.dataset.profile));
|
||||
if (action === "delete-client") await deleteClient(decoded(button.dataset.client));
|
||||
if (action === "copy") await copyText(button.dataset.copy || "");
|
||||
}
|
||||
|
||||
function openUploadDialog(fixedPolicy = "") {
|
||||
openModal("Policy Repository", fixedPolicy ? "Neue Richtlinienversion" : "Richtlinie hochladen", `
|
||||
<form id="upload-form">
|
||||
<div class="form-grid">
|
||||
<div class="field-group">
|
||||
<label for="upload-policy-name">Richtlinienname</label>
|
||||
<input id="upload-policy-name" name="policy" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" maxlength="64" value="${escapeHTML(fixedPolicy)}" ${fixedPolicy ? "disabled" : ""} required>
|
||||
<p class="field-help">Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich.</p>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label for="upload-file">Microsoft-GPO-Sicherung</label>
|
||||
<input id="upload-file" name="bundle" type="file" accept=".zip,application/zip" required>
|
||||
<p class="field-help">ZIP mit backup.xml und DomainSysvol/GPO.</p>
|
||||
</div>
|
||||
<div class="field-group full">
|
||||
<label for="upload-note">Änderungsnotiz</label>
|
||||
<textarea id="upload-note" name="note" maxlength="4096" placeholder="Zum Beispiel Change-ID und kurze Beschreibung"></textarea>
|
||||
</div>
|
||||
<div class="checkbox-row full">
|
||||
<input id="upload-force" name="force" type="checkbox">
|
||||
<label for="upload-force">Neue Version auch bei identischem semantischem Hash erzwingen</label>
|
||||
</div>
|
||||
</div>
|
||||
<p id="upload-error" class="form-error" role="alert"></p>
|
||||
<div id="upload-progress" class="upload-progress hidden"><div class="upload-progress-bar"></div></div>
|
||||
<div class="dialog-actions"><button class="button secondary" type="button" data-modal-close>Abbrechen</button><button class="button primary" type="submit">Prüfen und hochladen</button></div>
|
||||
</form>`);
|
||||
refs.modalContent.querySelector("[data-modal-close]").addEventListener("click", closeModal);
|
||||
refs.modalContent.querySelector("#upload-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const error = form.querySelector("#upload-error");
|
||||
const submit = form.querySelector("button[type='submit']");
|
||||
const progress = form.querySelector("#upload-progress");
|
||||
error.textContent = "";
|
||||
submit.disabled = true;
|
||||
submit.textContent = "Upload läuft …";
|
||||
progress.classList.remove("hidden");
|
||||
const policy = fixedPolicy || form.elements.policy.value.trim();
|
||||
const data = new FormData();
|
||||
data.append("bundle", form.elements.bundle.files[0]);
|
||||
data.append("note", form.elements.note.value);
|
||||
data.append("force", form.elements.force.checked ? "true" : "false");
|
||||
try {
|
||||
const result = await request(`/api/v1/admin/policies/${encodeURIComponent(policy)}/versions`, { method: "POST", body: data });
|
||||
closeModal();
|
||||
await loadAll();
|
||||
navigate("policies");
|
||||
toast("success", result.created ? "Version angelegt" : "Keine Änderung erkannt", result.created ? `${policy} · ${result.version.version}` : `Der semantische Inhalt entspricht bereits ${result.version.version}.`);
|
||||
} catch (uploadError) {
|
||||
error.textContent = uploadError.message;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = "Prüfen und hochladen";
|
||||
progress.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openProfileEditor(profile) {
|
||||
if (!state.policies.length) {
|
||||
toast("error", "Keine Richtlinien vorhanden", "Lade zuerst mindestens eine Richtlinie hoch.");
|
||||
return;
|
||||
}
|
||||
const editing = Boolean(profile);
|
||||
let rows = profile ? profile.policies.map((item) => ({ ...item })) : [{ policy: state.policies[0].name, version: "latest" }];
|
||||
openModal("Zuweisungen", editing ? "Profil bearbeiten" : "Profil erstellen", `
|
||||
<form id="profile-form">
|
||||
<div class="field-group">
|
||||
<label for="profile-name">Profilname</label>
|
||||
<input id="profile-name" name="name" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" maxlength="64" value="${escapeHTML(profile?.name || "")}" ${editing ? "disabled" : ""} required>
|
||||
</div>
|
||||
<div class="section-heading compact"><div><h2>Reihenfolge</h2><p>Später importierte Richtlinien können frühere Einstellungen überschreiben.</p></div><button id="add-profile-row" class="button secondary small" type="button">Richtlinie hinzufügen</button></div>
|
||||
<div id="profile-rows" class="profile-editor-rows"></div>
|
||||
<p id="profile-error" class="form-error" role="alert"></p>
|
||||
<div class="dialog-actions"><button class="button secondary" type="button" data-modal-close>Abbrechen</button><button class="button primary" type="submit">Profil speichern</button></div>
|
||||
</form>`, true);
|
||||
|
||||
const rowsElement = refs.modalContent.querySelector("#profile-rows");
|
||||
const renderRows = () => {
|
||||
rowsElement.innerHTML = rows.map((row, index) => {
|
||||
const policy = state.policies.find((item) => item.name === row.policy) || state.policies[0];
|
||||
if (!policy) return "";
|
||||
const versions = [...policy.versions].reverse();
|
||||
const validVersion = row.version === "latest" || versions.some((version) => version.version === row.version) ? row.version : "latest";
|
||||
row.policy = policy.name;
|
||||
row.version = validVersion;
|
||||
return `<div class="profile-editor-row" data-row="${index}">
|
||||
<span class="drag-number">${index + 1}</span>
|
||||
<select data-field="policy" aria-label="Richtlinie ${index + 1}">${state.policies.map((item) => `<option value="${escapeHTML(item.name)}" ${item.name === row.policy ? "selected" : ""}>${escapeHTML(item.name)}</option>`).join("")}</select>
|
||||
<select data-field="version" aria-label="Version ${index + 1}"><option value="latest" ${row.version === "latest" ? "selected" : ""}>latest – immer aktuell</option>${versions.map((version) => `<option value="${escapeHTML(version.version)}" ${version.version === row.version ? "selected" : ""}>${escapeHTML(version.version)}</option>`).join("")}</select>
|
||||
<div class="row-buttons">
|
||||
<button class="icon-button" type="button" data-row-action="up" title="Nach oben" ${index === 0 ? "disabled" : ""}>↑</button>
|
||||
<button class="icon-button" type="button" data-row-action="down" title="Nach unten" ${index === rows.length - 1 ? "disabled" : ""}>↓</button>
|
||||
<button class="icon-button" type="button" data-row-action="remove" title="Entfernen" ${rows.length === 1 ? "disabled" : ""}>×</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
};
|
||||
renderRows();
|
||||
|
||||
refs.modalContent.querySelector("[data-modal-close]").addEventListener("click", closeModal);
|
||||
refs.modalContent.querySelector("#add-profile-row").addEventListener("click", () => {
|
||||
const used = new Set(rows.map((row) => row.policy));
|
||||
const next = state.policies.find((policy) => !used.has(policy.name));
|
||||
if (!next) {
|
||||
toast("error", "Keine weitere Richtlinie", "Jede Richtlinie darf pro Profil nur einmal vorkommen.");
|
||||
return;
|
||||
}
|
||||
rows.push({ policy: next.name, version: "latest" });
|
||||
renderRows();
|
||||
});
|
||||
rowsElement.addEventListener("change", (event) => {
|
||||
const rowElement = event.target.closest("[data-row]");
|
||||
if (!rowElement) return;
|
||||
const index = Number(rowElement.dataset.row);
|
||||
if (event.target.dataset.field === "policy") {
|
||||
rows[index] = { policy: event.target.value, version: "latest" };
|
||||
renderRows();
|
||||
}
|
||||
if (event.target.dataset.field === "version") rows[index].version = event.target.value;
|
||||
});
|
||||
rowsElement.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-row-action]");
|
||||
if (!button) return;
|
||||
const index = Number(button.closest("[data-row]").dataset.row);
|
||||
if (button.dataset.rowAction === "up" && index > 0) [rows[index - 1], rows[index]] = [rows[index], rows[index - 1]];
|
||||
if (button.dataset.rowAction === "down" && index < rows.length - 1) [rows[index + 1], rows[index]] = [rows[index], rows[index + 1]];
|
||||
if (button.dataset.rowAction === "remove" && rows.length > 1) rows.splice(index, 1);
|
||||
renderRows();
|
||||
});
|
||||
refs.modalContent.querySelector("#profile-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const error = form.querySelector("#profile-error");
|
||||
const submit = form.querySelector("button[type='submit']");
|
||||
const name = profile?.name || form.elements.name.value.trim();
|
||||
const unique = new Set(rows.map((row) => row.policy));
|
||||
if (unique.size !== rows.length) {
|
||||
error.textContent = "Eine Richtlinie darf im Profil nur einmal vorkommen.";
|
||||
return;
|
||||
}
|
||||
error.textContent = "";
|
||||
submit.disabled = true;
|
||||
submit.textContent = "Speichert …";
|
||||
try {
|
||||
await request(`/api/v1/admin/profiles/${encodeURIComponent(name)}`, { method: "PUT", json: { policies: rows } });
|
||||
closeModal();
|
||||
await loadAll();
|
||||
navigate("profiles");
|
||||
toast("success", editing ? "Profil aktualisiert" : "Profil erstellt", `${name} enthält ${rows.length} Richtlinien.`);
|
||||
} catch (saveError) {
|
||||
error.textContent = saveError.message;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
submit.textContent = "Profil speichern";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePolicy(name) {
|
||||
const ok = await confirmAction("Richtlinie löschen", `Alle Versionen und ZIP-Artefakte von „${name}“ werden dauerhaft gelöscht. Profile, die diese Richtlinie verwenden, müssen vorher angepasst werden.`, "Richtlinie löschen");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await request(`/api/v1/admin/policies/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
await loadAll();
|
||||
toast("success", "Richtlinie gelöscht", name);
|
||||
} catch (error) {
|
||||
toast("error", "Löschen nicht möglich", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVersion(policy, version) {
|
||||
const ok = await confirmAction("Version löschen", `Die unveränderliche Version „${version}“ von „${policy}“ wird einschließlich ZIP-Artefakt gelöscht. Fest angeheftete Profilversionen müssen vorher geändert werden.`, "Version löschen");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await request(`/api/v1/admin/policies/${encodeURIComponent(policy)}/versions/${encodeURIComponent(version)}`, { method: "DELETE" });
|
||||
await loadAll();
|
||||
toast("success", "Version gelöscht", version);
|
||||
} catch (error) {
|
||||
toast("error", "Löschen nicht möglich", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProfile(name) {
|
||||
const ok = await confirmAction("Profil löschen", `Das Profil „${name}“ wird dauerhaft entfernt. Bereits konfigurierte Agents erhalten danach für dieses Profil HTTP 404.`, "Profil löschen");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await request(`/api/v1/admin/profiles/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
await loadAll();
|
||||
toast("success", "Profil gelöscht", name);
|
||||
} catch (error) {
|
||||
toast("error", "Löschen nicht möglich", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClient(clientID) {
|
||||
const ok = await confirmAction("Client-Eintrag entfernen", `Der zuletzt gespeicherte Status von „${clientID}“ wird entfernt. Der Agent erscheint bei seiner nächsten Meldung automatisch wieder.`, "Eintrag entfernen");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await request(`/api/v1/admin/clients/${encodeURIComponent(clientID)}`, { method: "DELETE" });
|
||||
await loadAll();
|
||||
toast("success", "Client-Eintrag entfernt", clientID);
|
||||
} catch (error) {
|
||||
toast("error", "Entfernen nicht möglich", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadVersion(policy, version, button) {
|
||||
const old = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = "Lädt …";
|
||||
try {
|
||||
const response = await request(`/api/v1/admin/policies/${encodeURIComponent(policy)}/versions/${encodeURIComponent(version)}/artifact`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${policy}-${version}.zip`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast("error", "ZIP konnte nicht geladen werden", error.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = old;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("success", "Kopiert", "Der Hash wurde in die Zwischenablage kopiert.");
|
||||
} catch (_) {
|
||||
toast("error", "Kopieren fehlgeschlagen", "Die Zwischenablage ist für diese Seite nicht verfügbar.");
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(eyebrow, title, content, wide = false) {
|
||||
refs.modalEyebrow.textContent = eyebrow;
|
||||
refs.modalTitle.textContent = title;
|
||||
refs.modalContent.innerHTML = content;
|
||||
refs.modal.classList.toggle("wide-modal", wide);
|
||||
refs.modalBackdrop.classList.remove("hidden");
|
||||
document.body.style.overflow = "hidden";
|
||||
setTimeout(() => refs.modalContent.querySelector("input, select, textarea, button")?.focus(), 0);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
refs.modalBackdrop.classList.add("hidden");
|
||||
refs.modal.classList.remove("wide-modal");
|
||||
refs.modalContent.innerHTML = "";
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
function confirmAction(title, message, submitLabel) {
|
||||
refs.confirmTitle.textContent = title;
|
||||
refs.confirmMessage.textContent = message;
|
||||
refs.confirmSubmit.textContent = submitLabel;
|
||||
refs.confirmBackdrop.classList.remove("hidden");
|
||||
return new Promise((resolve) => {
|
||||
const finish = (value) => {
|
||||
refs.confirmBackdrop.classList.add("hidden");
|
||||
refs.confirmSubmit.removeEventListener("click", accept);
|
||||
refs.confirmCancel.removeEventListener("click", cancel);
|
||||
refs.confirmBackdrop.removeEventListener("click", backdrop);
|
||||
resolve(value);
|
||||
};
|
||||
const accept = () => finish(true);
|
||||
const cancel = () => finish(false);
|
||||
const backdrop = (event) => { if (event.target === refs.confirmBackdrop) finish(false); };
|
||||
refs.confirmSubmit.addEventListener("click", accept);
|
||||
refs.confirmCancel.addEventListener("click", cancel);
|
||||
refs.confirmBackdrop.addEventListener("click", backdrop);
|
||||
refs.confirmCancel.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function toast(type, title, message) {
|
||||
const node = document.createElement("div");
|
||||
node.className = `toast ${type}`;
|
||||
node.innerHTML = `<span class="toast-bar"></span><div><strong>${escapeHTML(title)}</strong><p>${escapeHTML(message)}</p></div><button type="button" aria-label="Meldung schließen">×</button>`;
|
||||
node.querySelector("button").addEventListener("click", () => node.remove());
|
||||
refs.toastRegion.appendChild(node);
|
||||
setTimeout(() => node.remove(), 6000);
|
||||
}
|
||||
|
||||
function loadingMarkup(message) {
|
||||
return `<div class="loading-state"><div><div class="spinner"></div>${escapeHTML(message)}</div></div>`;
|
||||
}
|
||||
|
||||
function errorState(message) {
|
||||
return emptyState("!", "Daten konnten nicht geladen werden", message, '<button class="button primary" data-action="retry">Erneut versuchen</button>');
|
||||
}
|
||||
|
||||
function emptyState(icon, title, message, action) {
|
||||
return `<div class="empty-state"><div class="empty-state-icon">${escapeHTML(icon)}</div><h2>${escapeHTML(title)}</h2><p>${escapeHTML(message)}</p>${action}</div>`;
|
||||
}
|
||||
|
||||
function emptyInline(message) {
|
||||
return `<div class="empty-state empty-inline"><p>${escapeHTML(message)}</p></div>`;
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>GPO Distributor</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-view" class="login-view">
|
||||
<main class="login-card" aria-labelledby="login-title">
|
||||
<div class="brand-mark" aria-hidden="true">GP</div>
|
||||
<p class="eyebrow">Zentrale Richtlinienverwaltung</p>
|
||||
<h1 id="login-title">GPO Distributor</h1>
|
||||
<p class="muted">Melde dich mit dem Admin-Token des Backends an.</p>
|
||||
<form id="login-form" class="stack">
|
||||
<label for="admin-token">Admin-Token</label>
|
||||
<div class="password-field">
|
||||
<input id="admin-token" name="token" type="password" autocomplete="current-password" required autofocus>
|
||||
<button id="toggle-token" class="icon-button" type="button" aria-label="Token anzeigen">Anzeigen</button>
|
||||
</div>
|
||||
<p id="login-error" class="form-error" role="alert"></p>
|
||||
<button class="button primary wide" type="submit">Anmelden</button>
|
||||
</form>
|
||||
<p class="login-hint">Die Browser-Session ist acht Stunden gültig. Das Token wird nicht im Browser gespeichert.</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="app-view" class="app-shell hidden">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark small" aria-hidden="true">GP</div>
|
||||
<div>
|
||||
<strong>GPO Distributor</strong>
|
||||
<span id="server-version">Backend</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-list" aria-label="Hauptnavigation">
|
||||
<button class="nav-item active" data-route="dashboard">
|
||||
<span class="nav-icon">⌂</span><span>Übersicht</span>
|
||||
</button>
|
||||
<button class="nav-item" data-route="policies">
|
||||
<span class="nav-icon">▤</span><span>Richtlinien</span><span id="nav-policy-count" class="nav-count">0</span>
|
||||
</button>
|
||||
<button class="nav-item" data-route="profiles">
|
||||
<span class="nav-icon">◫</span><span>Profile</span><span id="nav-profile-count" class="nav-count">0</span>
|
||||
</button>
|
||||
<button class="nav-item" data-route="clients">
|
||||
<span class="nav-icon">◇</span><span>Clients</span><span id="nav-client-count" class="nav-count">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="connection-state"><span class="status-dot"></span> Verbunden</div>
|
||||
<button id="logout-button" class="button ghost dark wide" type="button">Abmelden</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main-column">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p id="page-eyebrow" class="eyebrow">Verwaltung</p>
|
||||
<h1 id="page-title">Übersicht</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="last-refresh" class="refresh-time"></span>
|
||||
<button id="refresh-button" class="button secondary" type="button">Aktualisieren</button>
|
||||
<button id="primary-action" class="button primary hidden" type="button"></button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="page-content" class="page-content" tabindex="-1"></main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-backdrop" class="modal-backdrop hidden" role="presentation">
|
||||
<section id="modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<header class="modal-header">
|
||||
<div>
|
||||
<p id="modal-eyebrow" class="eyebrow"></p>
|
||||
<h2 id="modal-title"></h2>
|
||||
</div>
|
||||
<button id="modal-close" class="icon-button close-button" type="button" aria-label="Dialog schließen">×</button>
|
||||
</header>
|
||||
<div id="modal-content" class="modal-content"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="confirm-backdrop" class="modal-backdrop hidden" role="presentation">
|
||||
<section class="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message">
|
||||
<div class="danger-icon" aria-hidden="true">!</div>
|
||||
<h2 id="confirm-title">Aktion bestätigen</h2>
|
||||
<p id="confirm-message"></p>
|
||||
<div class="dialog-actions">
|
||||
<button id="confirm-cancel" class="button secondary" type="button">Abbrechen</button>
|
||||
<button id="confirm-submit" class="button danger" type="button">Löschen</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="toast-region" class="toast-region" aria-live="polite" aria-atomic="true"></div>
|
||||
<script src="/ui/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,367 @@
|
||||
:root {
|
||||
--navy-950: #0b1428;
|
||||
--navy-900: #111d35;
|
||||
--navy-800: #1a2947;
|
||||
--blue-700: #1e5bb8;
|
||||
--blue-600: #2871d5;
|
||||
--blue-100: #eaf2ff;
|
||||
--slate-950: #172033;
|
||||
--slate-700: #44516a;
|
||||
--slate-600: #647088;
|
||||
--slate-500: #7b879d;
|
||||
--slate-300: #cbd3df;
|
||||
--slate-200: #dde3ec;
|
||||
--slate-100: #edf1f6;
|
||||
--slate-50: #f6f8fb;
|
||||
--white: #ffffff;
|
||||
--green-700: #14724a;
|
||||
--green-100: #e1f5eb;
|
||||
--amber-700: #976315;
|
||||
--amber-100: #fff2d8;
|
||||
--red-700: #b4232f;
|
||||
--red-100: #fde9eb;
|
||||
--shadow-sm: 0 1px 2px rgba(12, 25, 48, .06), 0 1px 4px rgba(12, 25, 48, .04);
|
||||
--shadow-lg: 0 22px 55px rgba(11, 20, 40, .24);
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { min-height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--slate-950);
|
||||
background: var(--slate-50);
|
||||
line-height: 1.45;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.hidden { display: none !important; }
|
||||
.muted { color: var(--slate-600); }
|
||||
.small-text { font-size: .85rem; }
|
||||
.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.eyebrow {
|
||||
margin: 0 0 3px;
|
||||
color: var(--blue-700);
|
||||
font-size: .72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 30px;
|
||||
background:
|
||||
radial-gradient(circle at 12% 14%, rgba(40, 113, 213, .34), transparent 28%),
|
||||
radial-gradient(circle at 82% 72%, rgba(46, 196, 143, .14), transparent 25%),
|
||||
linear-gradient(145deg, var(--navy-950), #15284a 64%, #17365b);
|
||||
}
|
||||
.login-card {
|
||||
width: min(430px, 100%);
|
||||
padding: 38px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: 22px;
|
||||
background: rgba(255,255,255,.97);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.login-card h1 { margin: 6px 0 9px; font-size: 2rem; letter-spacing: -.04em; }
|
||||
.login-card .brand-mark { margin-bottom: 24px; }
|
||||
.login-hint { margin: 22px 0 0; color: var(--slate-500); font-size: .8rem; }
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
color: var(--white);
|
||||
background: linear-gradient(145deg, var(--blue-600), #17478f);
|
||||
box-shadow: 0 8px 18px rgba(30,91,184,.28);
|
||||
font-size: .9rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.brand-mark.small { width: 38px; height: 38px; border-radius: 10px; box-shadow: none; font-size: .72rem; }
|
||||
|
||||
.stack { display: grid; gap: 10px; margin-top: 25px; }
|
||||
label { color: var(--slate-700); font-size: .86rem; font-weight: 700; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--slate-300);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
color: var(--slate-950);
|
||||
background: var(--white);
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
textarea { resize: vertical; min-height: 92px; }
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--blue-600);
|
||||
box-shadow: 0 0 0 3px rgba(40,113,213,.14);
|
||||
}
|
||||
input:disabled, select:disabled { background: var(--slate-100); color: var(--slate-600); cursor: not-allowed; }
|
||||
.password-field { position: relative; }
|
||||
.password-field input { padding-right: 88px; }
|
||||
.password-field .icon-button { position: absolute; top: 50%; right: 7px; transform: translateY(-50%); }
|
||||
.form-error { min-height: 1.25em; margin: 0; color: var(--red-700); font-size: .84rem; }
|
||||
.field-help { margin: 5px 0 0; color: var(--slate-500); font-size: .78rem; }
|
||||
.field-group { display: grid; gap: 6px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.checkbox-row { display: flex; align-items: flex-start; gap: 9px; }
|
||||
.checkbox-row input { width: 17px; height: 17px; margin-top: 2px; }
|
||||
.checkbox-row label { font-weight: 600; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 250px minmax(0, 1fr); }
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 23px 18px 18px;
|
||||
color: var(--white);
|
||||
background: linear-gradient(180deg, var(--navy-950), var(--navy-900));
|
||||
}
|
||||
.sidebar-brand { display: flex; align-items: center; gap: 11px; padding: 0 8px 25px; border-bottom: 1px solid rgba(255,255,255,.08); }
|
||||
.sidebar-brand strong { display: block; font-size: .94rem; }
|
||||
.sidebar-brand span { display: block; margin-top: 2px; color: #94a7c6; font-size: .72rem; }
|
||||
.nav-list { display: grid; gap: 6px; margin-top: 22px; }
|
||||
.nav-item {
|
||||
display: grid;
|
||||
grid-template-columns: 25px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
color: #b6c5dc;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-weight: 650;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.nav-item:hover { color: var(--white); background: rgba(255,255,255,.065); }
|
||||
.nav-item.active { color: var(--white); background: rgba(40,113,213,.30); }
|
||||
.nav-icon { width: 22px; text-align: center; font-size: 1.15rem; }
|
||||
.nav-count { min-width: 24px; padding: 2px 7px; border-radius: 999px; color: #cbd8ea; background: rgba(255,255,255,.08); font-size: .7rem; text-align: center; }
|
||||
.sidebar-footer { margin-top: auto; display: grid; gap: 12px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,.08); }
|
||||
.connection-state { color: #a9bad2; font-size: .78rem; }
|
||||
.status-dot { display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: #42d39c; box-shadow: 0 0 0 3px rgba(66,211,156,.13); }
|
||||
|
||||
.main-column { min-width: 0; }
|
||||
.topbar {
|
||||
min-height: 102px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px 34px;
|
||||
border-bottom: 1px solid var(--slate-200);
|
||||
background: rgba(255,255,255,.92);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.topbar h1 { margin: 0; font-size: 1.7rem; letter-spacing: -.035em; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.refresh-time { color: var(--slate-500); font-size: .76rem; }
|
||||
.page-content { padding: 30px 34px 50px; outline: none; }
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 38px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: .84rem;
|
||||
font-weight: 750;
|
||||
transition: transform .12s, background .12s, border-color .12s, opacity .12s;
|
||||
}
|
||||
.button:hover { transform: translateY(-1px); }
|
||||
.button:disabled { cursor: not-allowed; opacity: .6; transform: none; }
|
||||
.button.primary { color: var(--white); background: var(--blue-600); box-shadow: 0 3px 8px rgba(40,113,213,.18); }
|
||||
.button.primary:hover { background: var(--blue-700); }
|
||||
.button.secondary { color: var(--slate-700); border-color: var(--slate-300); background: var(--white); }
|
||||
.button.secondary:hover { border-color: var(--slate-500); }
|
||||
.button.ghost { color: var(--slate-600); background: transparent; }
|
||||
.button.ghost.dark { color: #cad5e5; border-color: rgba(255,255,255,.13); }
|
||||
.button.danger { color: var(--white); background: var(--red-700); }
|
||||
.button.danger-soft { color: var(--red-700); border-color: transparent; background: var(--red-100); }
|
||||
.button.small { min-height: 31px; padding: 5px 10px; font-size: .76rem; }
|
||||
.button.wide { width: 100%; }
|
||||
.icon-button { padding: 6px 9px; border: 0; border-radius: 7px; color: var(--slate-600); background: transparent; font-size: .77rem; font-weight: 750; }
|
||||
.icon-button:hover { color: var(--slate-950); background: var(--slate-100); }
|
||||
.close-button { font-size: 1.6rem; line-height: 1; }
|
||||
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||
.stat-card, .panel, .policy-card, .profile-card {
|
||||
border: 1px solid var(--slate-200);
|
||||
border-radius: var(--radius);
|
||||
background: var(--white);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stat-card { position: relative; overflow: hidden; padding: 21px; }
|
||||
.stat-card::after { content: ""; position: absolute; right: -15px; bottom: -22px; width: 76px; height: 76px; border-radius: 50%; background: var(--blue-100); }
|
||||
.stat-label { color: var(--slate-600); font-size: .78rem; font-weight: 700; }
|
||||
.stat-value { display: block; margin: 8px 0 4px; font-size: 2rem; font-weight: 800; letter-spacing: -.05em; }
|
||||
.stat-detail { color: var(--slate-500); font-size: .75rem; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(300px, .8fr); gap: 20px; margin-top: 20px; }
|
||||
.panel { min-width: 0; }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 20px; border-bottom: 1px solid var(--slate-200); }
|
||||
.panel-header h2, .section-heading h2 { margin: 0; font-size: 1rem; }
|
||||
.panel-body { padding: 4px 20px 14px; }
|
||||
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 17px; }
|
||||
.section-heading p { margin: 3px 0 0; color: var(--slate-600); font-size: .84rem; }
|
||||
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th { padding: 12px 9px; color: var(--slate-500); border-bottom: 1px solid var(--slate-200); font-size: .7rem; letter-spacing: .04em; text-align: left; text-transform: uppercase; }
|
||||
.data-table td { padding: 13px 9px; border-bottom: 1px solid var(--slate-100); font-size: .82rem; vertical-align: middle; }
|
||||
.data-table tr:last-child td { border-bottom: 0; }
|
||||
.data-table .actions { text-align: right; white-space: nowrap; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
.badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 999px; font-size: .7rem; font-weight: 750; white-space: nowrap; }
|
||||
.badge.success { color: var(--green-700); background: var(--green-100); }
|
||||
.badge.warning { color: var(--amber-700); background: var(--amber-100); }
|
||||
.badge.error { color: var(--red-700); background: var(--red-100); }
|
||||
.badge.neutral { color: var(--slate-700); background: var(--slate-100); }
|
||||
.badge.blue { color: var(--blue-700); background: var(--blue-100); }
|
||||
|
||||
.health-ring { display: grid; place-items: center; padding: 28px 20px; }
|
||||
.ring-chart { position: relative; width: 150px; height: 150px; }
|
||||
.ring-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
.ring-track, .ring-progress { fill: none; stroke-width: 4.2; }
|
||||
.ring-track { stroke: var(--slate-100); }
|
||||
.ring-progress { stroke: var(--green-700); stroke-linecap: round; }
|
||||
.ring-center { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; }
|
||||
.ring-value { display: block; font-size: 1.75rem; font-weight: 850; line-height: 1; }
|
||||
.ring-label { margin-top: 5px; color: var(--slate-500); font-size: .72rem; }
|
||||
.health-legend { width: 100%; display: grid; gap: 9px; margin-top: 22px; }
|
||||
.legend-row { display: flex; justify-content: space-between; color: var(--slate-600); font-size: .8rem; }
|
||||
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 17px; }
|
||||
.toolbar-group { display: flex; align-items: center; gap: 9px; }
|
||||
.search-input { max-width: 320px; padding-left: 35px; background-image: linear-gradient(transparent, transparent); }
|
||||
.search-wrap { position: relative; min-width: 260px; }
|
||||
.search-wrap::before { content: "⌕"; position: absolute; left: 12px; top: 50%; transform: translateY(-50%); z-index: 1; color: var(--slate-500); }
|
||||
.search-wrap input { padding-left: 35px; }
|
||||
.filter-select { width: auto; min-width: 150px; }
|
||||
|
||||
.policy-list, .profile-grid { display: grid; gap: 14px; }
|
||||
.profile-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.policy-card { overflow: hidden; }
|
||||
.policy-summary { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 20px; padding: 18px 20px; }
|
||||
.policy-title-row { display: flex; align-items: center; gap: 10px; }
|
||||
.policy-title-row h2 { margin: 0; font-size: 1rem; }
|
||||
.policy-meta { display: flex; flex-wrap: wrap; gap: 15px; margin-top: 8px; color: var(--slate-500); font-size: .76rem; }
|
||||
.policy-actions { display: flex; gap: 8px; }
|
||||
.version-list { border-top: 1px solid var(--slate-200); background: #fbfcfe; }
|
||||
.version-row { display: grid; grid-template-columns: minmax(190px, 1.1fr) minmax(160px, .8fr) minmax(180px, 1.4fr) auto; align-items: center; gap: 15px; padding: 14px 20px; border-bottom: 1px solid var(--slate-200); }
|
||||
.version-row:last-child { border-bottom: 0; }
|
||||
.version-id { font-size: .78rem; font-weight: 750; }
|
||||
.version-note { color: var(--slate-600); font-size: .78rem; }
|
||||
.hash-line { display: flex; align-items: center; gap: 7px; min-width: 0; color: var(--slate-500); font-size: .72rem; }
|
||||
.profile-card { padding: 19px; }
|
||||
.profile-card-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 13px; }
|
||||
.profile-card h2 { margin: 0; font-size: 1rem; }
|
||||
.profile-card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--slate-100); }
|
||||
.policy-stack { display: grid; gap: 7px; margin-top: 15px; }
|
||||
.policy-stack-item { display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; align-items: center; padding: 8px 10px; border-radius: 8px; background: var(--slate-50); font-size: .78rem; }
|
||||
.order-number { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: var(--blue-700); background: var(--blue-100); font-size: .67rem; font-weight: 800; }
|
||||
|
||||
.empty-state { display: grid; place-items: center; min-height: 290px; padding: 35px; border: 1px dashed var(--slate-300); border-radius: var(--radius); background: var(--white); text-align: center; }
|
||||
.empty-state-icon { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 14px; border-radius: 14px; color: var(--blue-700); background: var(--blue-100); font-size: 1.5rem; }
|
||||
.empty-state h2 { margin: 0; font-size: 1.05rem; }
|
||||
.empty-state p { max-width: 430px; margin: 7px 0 18px; color: var(--slate-600); font-size: .84rem; }
|
||||
.loading-state { display: grid; place-items: center; min-height: 350px; color: var(--slate-500); }
|
||||
.spinner { width: 30px; height: 30px; margin-bottom: 12px; border: 3px solid var(--slate-200); border-top-color: var(--blue-600); border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 24px; background: rgba(7, 15, 30, .58); backdrop-filter: blur(3px); }
|
||||
.modal { width: min(720px, 100%); max-height: calc(100vh - 48px); overflow: auto; border-radius: 17px; background: var(--white); box-shadow: var(--shadow-lg); }
|
||||
.modal.wide-modal { width: min(880px, 100%); }
|
||||
.modal-header { position: sticky; top: 0; z-index: 2; display: flex; justify-content: space-between; align-items: flex-start; padding: 20px 22px; border-bottom: 1px solid var(--slate-200); background: rgba(255,255,255,.96); backdrop-filter: blur(8px); }
|
||||
.modal-header h2 { margin: 0; font-size: 1.25rem; }
|
||||
.modal-content { padding: 22px; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
|
||||
.confirm-dialog { width: min(430px, 100%); padding: 28px; border-radius: 16px; background: var(--white); box-shadow: var(--shadow-lg); text-align: center; }
|
||||
.confirm-dialog h2 { margin: 12px 0 7px; }
|
||||
.confirm-dialog p { margin: 0; color: var(--slate-600); }
|
||||
.confirm-dialog .dialog-actions { justify-content: center; }
|
||||
.danger-icon { display: grid; place-items: center; width: 46px; height: 46px; margin: 0 auto; border-radius: 50%; color: var(--red-700); background: var(--red-100); font-size: 1.25rem; font-weight: 900; }
|
||||
|
||||
.profile-editor-rows { display: grid; gap: 10px; margin-top: 16px; }
|
||||
.profile-editor-row { display: grid; grid-template-columns: 34px minmax(170px, 1.1fr) minmax(180px, 1.25fr) auto; align-items: center; gap: 9px; padding: 10px; border: 1px solid var(--slate-200); border-radius: 10px; background: var(--slate-50); }
|
||||
.drag-number { color: var(--slate-500); font-size: .75rem; text-align: center; }
|
||||
.row-buttons { display: flex; gap: 4px; }
|
||||
.row-buttons .icon-button { background: var(--white); border: 1px solid var(--slate-200); }
|
||||
.upload-progress { height: 7px; margin-top: 14px; overflow: hidden; border-radius: 999px; background: var(--slate-100); }
|
||||
.upload-progress-bar { width: 35%; height: 100%; border-radius: inherit; background: var(--blue-600); animation: progress 1.1s ease-in-out infinite alternate; }
|
||||
@keyframes progress { from { transform: translateX(-80%); } to { transform: translateX(280%); } }
|
||||
|
||||
.toast-region { position: fixed; right: 22px; bottom: 22px; z-index: 90; display: grid; gap: 9px; width: min(380px, calc(100vw - 44px)); }
|
||||
.toast { display: grid; grid-template-columns: 10px 1fr auto; align-items: start; gap: 10px; padding: 13px 14px; border: 1px solid var(--slate-200); border-radius: 11px; background: var(--white); box-shadow: 0 12px 28px rgba(11,20,40,.18); animation: toast-in .18s ease-out; }
|
||||
.toast-bar { width: 4px; min-height: 34px; border-radius: 99px; background: var(--blue-600); }
|
||||
.toast.success .toast-bar { background: var(--green-700); }
|
||||
.toast.error .toast-bar { background: var(--red-700); }
|
||||
.toast strong { display: block; font-size: .82rem; }
|
||||
.toast p { margin: 3px 0 0; color: var(--slate-600); font-size: .76rem; }
|
||||
.toast button { border: 0; color: var(--slate-500); background: transparent; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
|
||||
|
||||
.client-message { max-width: 300px; }
|
||||
.client-name { font-weight: 750; }
|
||||
.client-id { margin-top: 2px; color: var(--slate-500); font-size: .7rem; }
|
||||
.client-stale { opacity: .72; }
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.profile-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; padding: 14px; }
|
||||
.sidebar-brand { padding-bottom: 13px; }
|
||||
.nav-list { grid-template-columns: repeat(4, 1fr); margin-top: 13px; }
|
||||
.nav-item { display: flex; justify-content: center; padding: 9px; }
|
||||
.nav-item .nav-icon, .nav-count { display: none; }
|
||||
.sidebar-footer { display: none; }
|
||||
.topbar { min-height: 88px; padding: 16px 20px; }
|
||||
.refresh-time { display: none; }
|
||||
.page-content { padding: 22px 20px 38px; }
|
||||
.version-row { grid-template-columns: 1fr; gap: 8px; }
|
||||
.profile-editor-row { grid-template-columns: 28px 1fr; }
|
||||
.profile-editor-row select { grid-column: 2; }
|
||||
.row-buttons { grid-column: 2; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.login-card { padding: 27px 23px; }
|
||||
.nav-item { font-size: .72rem; }
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
.topbar { align-items: flex-start; }
|
||||
.topbar-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||||
.topbar .secondary { display: none; }
|
||||
.page-content { padding-left: 14px; padding-right: 14px; }
|
||||
.toolbar { align-items: stretch; flex-direction: column; }
|
||||
.toolbar-group { flex-wrap: wrap; }
|
||||
.search-wrap { min-width: 100%; }
|
||||
.filter-select { flex: 1; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.policy-summary { grid-template-columns: 1fr; }
|
||||
.policy-actions { justify-content: flex-start; }
|
||||
.modal-backdrop { padding: 10px; }
|
||||
.modal { max-height: calc(100vh - 20px); }
|
||||
}
|
||||
|
||||
.panel-spaced { margin-top: 20px; }
|
||||
.section-heading.compact { margin-top: 22px; margin-bottom: 0; }
|
||||
.empty-state.empty-inline { min-height: 180px; border: 0; }
|
||||
@@ -0,0 +1,286 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
adminSessionCookie = "gpo_admin_session"
|
||||
adminSessionTTL = 8 * time.Hour
|
||||
)
|
||||
|
||||
//go:embed ui/*
|
||||
var embeddedUI embed.FS
|
||||
|
||||
type adminSession struct {
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
CSRF string `json:"csrf"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
func (s *Server) registerWebUI() {
|
||||
s.mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/", http.StatusTemporaryRedirect)
|
||||
})
|
||||
s.mux.HandleFunc("POST /ui/api/session", s.createWebSession)
|
||||
s.mux.HandleFunc("GET /ui/api/session", s.getWebSession)
|
||||
s.mux.HandleFunc("DELETE /ui/api/session", s.deleteWebSession)
|
||||
s.mux.HandleFunc("GET /ui", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/", http.StatusTemporaryRedirect)
|
||||
})
|
||||
s.mux.HandleFunc("GET /ui/{$}", s.serveUIIndex)
|
||||
s.mux.HandleFunc("GET /ui/{asset...}", s.serveUIAsset)
|
||||
}
|
||||
|
||||
func (s *Server) serveUIIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/ui/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.serveEmbeddedFile(w, r, "ui/index.html")
|
||||
}
|
||||
|
||||
func (s *Server) serveUIAsset(w http.ResponseWriter, r *http.Request) {
|
||||
asset := path.Clean(r.PathValue("asset"))
|
||||
if asset == "." || strings.HasPrefix(asset, "../") || strings.Contains(asset, "\\") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if asset != "app.js" && asset != "styles.css" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.serveEmbeddedFile(w, r, "ui/"+asset)
|
||||
}
|
||||
|
||||
func (s *Server) serveEmbeddedFile(w http.ResponseWriter, r *http.Request, name string) {
|
||||
data, err := fs.ReadFile(embeddedUI, name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
contentType := mime.TypeByExtension(path.Ext(name))
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(data)
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
if strings.HasSuffix(name, ".html") {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) createWebSession(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &request, 64<<10); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if !constantTimeEqual(request.Token, s.cfg.AdminToken) {
|
||||
writeError(w, http.StatusUnauthorized, errors.New("invalid credentials"))
|
||||
return
|
||||
}
|
||||
csrf, err := randomToken(32)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
nonce, err := randomToken(16)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
session := adminSession{ExpiresAt: time.Now().Add(adminSessionTTL).Unix(), CSRF: csrf, Nonce: nonce}
|
||||
value, err := s.signAdminSession(session)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: int(adminSessionTTL.Seconds()),
|
||||
Expires: time.Unix(session.ExpiresAt, 0),
|
||||
HttpOnly: true,
|
||||
Secure: requestIsHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"csrf_token": csrf,
|
||||
"expires_at": time.Unix(session.ExpiresAt, 0).UTC(),
|
||||
"version": s.cfg.ServerVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getWebSession(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := s.readAdminSession(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, errors.New("not authenticated"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"csrf_token": session.CSRF,
|
||||
"expires_at": time.Unix(session.ExpiresAt, 0).UTC(),
|
||||
"version": s.cfg.ServerVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) deleteWebSession(w http.ResponseWriter, r *http.Request) {
|
||||
if session, err := s.readAdminSession(r); err == nil {
|
||||
provided := r.Header.Get("X-CSRF-Token")
|
||||
if !constantTimeEqual(provided, session.CSRF) {
|
||||
writeError(w, http.StatusForbidden, errors.New("invalid CSRF token"))
|
||||
return
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(1, 0),
|
||||
HttpOnly: true,
|
||||
Secure: requestIsHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if auth != "" {
|
||||
if !strings.HasPrefix(auth, "Bearer ") || !constantTimeEqual(strings.TrimPrefix(auth, "Bearer "), s.cfg.AdminToken) {
|
||||
writeError(w, http.StatusForbidden, errors.New("invalid bearer token"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.readAdminSession(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, errors.New("not authenticated"))
|
||||
return
|
||||
}
|
||||
if methodNeedsCSRF(r.Method) && !constantTimeEqual(r.Header.Get("X-CSRF-Token"), session.CSRF) {
|
||||
writeError(w, http.StatusForbidden, errors.New("invalid CSRF token"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) signAdminSession(session adminSession) (string, error) {
|
||||
payload, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||
mac := hmac.New(sha256.New, []byte(s.cfg.AdminToken))
|
||||
_, _ = mac.Write([]byte("gpo-web-session-v1\x00"))
|
||||
_, _ = mac.Write([]byte(encoded))
|
||||
return encoded + "." + hex.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (s *Server) readAdminSession(r *http.Request) (adminSession, error) {
|
||||
cookie, err := r.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
return adminSession{}, err
|
||||
}
|
||||
encoded, signature, ok := strings.Cut(cookie.Value, ".")
|
||||
if !ok || encoded == "" || signature == "" {
|
||||
return adminSession{}, errors.New("malformed session")
|
||||
}
|
||||
sig, err := hex.DecodeString(signature)
|
||||
if err != nil {
|
||||
return adminSession{}, errors.New("malformed session signature")
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(s.cfg.AdminToken))
|
||||
_, _ = mac.Write([]byte("gpo-web-session-v1\x00"))
|
||||
_, _ = mac.Write([]byte(encoded))
|
||||
if !hmac.Equal(sig, mac.Sum(nil)) {
|
||||
return adminSession{}, errors.New("invalid session signature")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return adminSession{}, errors.New("malformed session payload")
|
||||
}
|
||||
var session adminSession
|
||||
if err := json.Unmarshal(payload, &session); err != nil {
|
||||
return adminSession{}, errors.New("malformed session payload")
|
||||
}
|
||||
if session.ExpiresAt <= time.Now().Unix() || session.CSRF == "" || session.Nonce == "" {
|
||||
return adminSession{}, errors.New("expired session")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func randomToken(size int) (string, error) {
|
||||
buffer := make([]byte, size)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", fmt.Errorf("generate random token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
||||
}
|
||||
|
||||
func constantTimeEqual(provided, expected string) bool {
|
||||
if len(provided) != len(expected) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func methodNeedsCSRF(method string) bool {
|
||||
return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions
|
||||
}
|
||||
|
||||
func requestIsHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https")
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
if strings.HasPrefix(r.URL.Path, "/ui") || r.URL.Path == "/" {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gpo-distributor/internal/store"
|
||||
)
|
||||
|
||||
func TestWebSessionAndCSRF(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api, err := New(st, Config{
|
||||
AdminToken: "admin-secret-with-sufficient-entropy",
|
||||
ClientToken: "client-secret-with-sufficient-entropy",
|
||||
SigningKey: "manifest-secret-with-sufficient-entropy",
|
||||
ServerVersion: "test",
|
||||
Logger: log.New(io.Discard, "", 0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := api.Handler()
|
||||
|
||||
loginBody := bytes.NewBufferString(`{"token":"admin-secret-with-sufficient-entropy"}`)
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/ui/api/session", loginBody)
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(loginRec, loginReq)
|
||||
if loginRec.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", loginRec.Code, loginRec.Body.String())
|
||||
}
|
||||
cookies := loginRec.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name != adminSessionCookie {
|
||||
t.Fatalf("expected admin session cookie, got %#v", cookies)
|
||||
}
|
||||
var loginResponse struct {
|
||||
CSRF string `json:"csrf_token"`
|
||||
}
|
||||
if err := json.Unmarshal(loginRec.Body.Bytes(), &loginResponse); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loginResponse.CSRF == "" {
|
||||
t.Fatal("missing CSRF token")
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/admin/policies", nil)
|
||||
listReq.AddCookie(cookies[0])
|
||||
listRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/missing", nil)
|
||||
deleteReq.AddCookie(cookies[0])
|
||||
deleteRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete without CSRF status=%d body=%s", deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
|
||||
deleteReq = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/missing", nil)
|
||||
deleteReq.AddCookie(cookies[0])
|
||||
deleteReq.Header.Set("X-CSRF-Token", loginResponse.CSRF)
|
||||
deleteRec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("delete with CSRF status=%d body=%s", deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedUIAndBearerCompatibility(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api, err := New(st, Config{
|
||||
AdminToken: "admin-token",
|
||||
ClientToken: "client-token",
|
||||
SigningKey: "signing-key",
|
||||
Logger: log.New(io.Discard, "", 0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := api.Handler()
|
||||
|
||||
uiReq := httptest.NewRequest(http.MethodGet, "/ui/", nil)
|
||||
uiRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(uiRec, uiReq)
|
||||
if uiRec.Code != http.StatusOK {
|
||||
t.Fatalf("ui status=%d", uiRec.Code)
|
||||
}
|
||||
if got := uiRec.Header().Get("Content-Security-Policy"); got == "" {
|
||||
t.Fatal("missing CSP header")
|
||||
}
|
||||
if !bytes.Contains(uiRec.Body.Bytes(), []byte("GPO Distributor")) {
|
||||
t.Fatal("embedded UI body is unexpected")
|
||||
}
|
||||
|
||||
apiReq := httptest.NewRequest(http.MethodGet, "/api/v1/admin/profiles", nil)
|
||||
apiReq.Header.Set("Authorization", "Bearer admin-token")
|
||||
apiRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(apiRec, apiReq)
|
||||
if apiRec.Code != http.StatusOK {
|
||||
t.Fatalf("bearer status=%d body=%s", apiRec.Code, apiRec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type PolicyVersion struct {
|
||||
Version string `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Note string `json:"note,omitempty"`
|
||||
ArtifactPath string `json:"artifact_path"`
|
||||
ArtifactHash string `json:"artifact_sha256"`
|
||||
SemanticHash string `json:"semantic_sha256"`
|
||||
Size int64 `json:"size"`
|
||||
FileCount int `json:"file_count"`
|
||||
PolicyFiles int `json:"policy_file_count"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
Name string `json:"name"`
|
||||
Versions []PolicyVersion `json:"versions"`
|
||||
}
|
||||
|
||||
type ProfilePolicy struct {
|
||||
Policy string `json:"policy"`
|
||||
Version string `json:"version"` // "latest" or an exact version
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
Policies []ProfilePolicy `json:"policies"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ResolvedPolicy struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SemanticHash string `json:"semantic_sha256"`
|
||||
Size int64 `json:"size"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
Profile string `json:"profile"`
|
||||
ProfileUpdated time.Time `json:"profile_updated_at"`
|
||||
Generation string `json:"generation"`
|
||||
Policies []ResolvedPolicy `json:"policies"`
|
||||
}
|
||||
|
||||
type ClientReport struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Profile string `json:"profile"`
|
||||
Generation string `json:"generation,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
AgentVersion string `json:"agent_version,omitempty"`
|
||||
ReportedAt time.Time `json:"reported_at"`
|
||||
AppliedAt time.Time `json:"applied_at,omitempty"`
|
||||
OperatingSystem string `json:"operating_system,omitempty"`
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
Policies map[string]*Policy `json:"policies"`
|
||||
Profiles map[string]*Profile `json:"profiles"`
|
||||
Clients map[string]ClientReport `json:"clients"`
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gpo-distributor/internal/bundle"
|
||||
"gpo-distributor/internal/model"
|
||||
)
|
||||
|
||||
var validName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
root string
|
||||
catalog model.Catalog
|
||||
}
|
||||
|
||||
func Open(root string) (*Store, error) {
|
||||
if root == "" {
|
||||
return nil, errors.New("data directory is required")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "artifacts"), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{root: root}
|
||||
s.catalog = model.Catalog{
|
||||
Policies: map[string]*model.Policy{},
|
||||
Profiles: map[string]*model.Profile{},
|
||||
Clients: map[string]model.ClientReport{},
|
||||
}
|
||||
filename := filepath.Join(root, "catalog.json")
|
||||
data, err := os.ReadFile(filename)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &s.catalog); err != nil {
|
||||
return nil, fmt.Errorf("read catalog: %w", err)
|
||||
}
|
||||
}
|
||||
if s.catalog.Policies == nil {
|
||||
s.catalog.Policies = map[string]*model.Policy{}
|
||||
}
|
||||
if s.catalog.Profiles == nil {
|
||||
s.catalog.Profiles = map[string]*model.Profile{}
|
||||
}
|
||||
if s.catalog.Clients == nil {
|
||||
s.catalog.Clients = map[string]model.ClientReport{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func ValidateName(name string) error {
|
||||
if !validName.MatchString(name) {
|
||||
return fmt.Errorf("name must match %s", validName.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ImportPolicy(name, note, sourceZip string, inspection bundle.Inspection, now time.Time, force bool) (model.PolicyVersion, bool, error) {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return model.PolicyVersion{}, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
policy := s.catalog.Policies[name]
|
||||
if policy == nil {
|
||||
policy = &model.Policy{Name: name}
|
||||
s.catalog.Policies[name] = policy
|
||||
}
|
||||
if !force {
|
||||
for _, v := range policy.Versions {
|
||||
if v.SemanticHash == inspection.SemanticHash {
|
||||
return v, false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
versionID := fmt.Sprintf("v%s-%s", now.UTC().Format("20060102-150405"), inspection.SemanticHash[:10])
|
||||
for _, v := range policy.Versions {
|
||||
if v.Version == versionID {
|
||||
versionID = fmt.Sprintf("%s-%d", versionID, now.UnixNano()%100000)
|
||||
break
|
||||
}
|
||||
}
|
||||
rel := filepath.Join("artifacts", name, versionID+".zip")
|
||||
dst := filepath.Join(s.root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return model.PolicyVersion{}, false, err
|
||||
}
|
||||
if err := copyFileAtomic(sourceZip, dst); err != nil {
|
||||
return model.PolicyVersion{}, false, err
|
||||
}
|
||||
|
||||
v := model.PolicyVersion{
|
||||
Version: versionID,
|
||||
CreatedAt: now.UTC(),
|
||||
Note: strings.TrimSpace(note),
|
||||
ArtifactPath: filepath.ToSlash(rel),
|
||||
ArtifactHash: inspection.ArtifactHash,
|
||||
SemanticHash: inspection.SemanticHash,
|
||||
Size: inspection.Size,
|
||||
FileCount: inspection.FileCount,
|
||||
PolicyFiles: inspection.PolicyFiles,
|
||||
}
|
||||
policy.Versions = append(policy.Versions, v)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
_ = os.Remove(dst)
|
||||
policy.Versions = policy.Versions[:len(policy.Versions)-1]
|
||||
return model.PolicyVersion{}, false, err
|
||||
}
|
||||
return v, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListPolicies() []model.Policy {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]model.Policy, 0, len(s.catalog.Policies))
|
||||
for _, p := range s.catalog.Policies {
|
||||
cp := model.Policy{Name: p.Name, Versions: append([]model.PolicyVersion(nil), p.Versions...)}
|
||||
out = append(out, cp)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) GetPolicy(name string) (model.Policy, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
p := s.catalog.Policies[name]
|
||||
if p == nil {
|
||||
return model.Policy{}, ErrNotFound
|
||||
}
|
||||
return model.Policy{Name: p.Name, Versions: append([]model.PolicyVersion(nil), p.Versions...)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetProfile(name string, refs []model.ProfilePolicy, now time.Time) (model.Profile, error) {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return model.Profile{}, err
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return model.Profile{}, errors.New("profile requires at least one policy")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
seen := map[string]bool{}
|
||||
for _, ref := range refs {
|
||||
if err := ValidateName(ref.Policy); err != nil {
|
||||
return model.Profile{}, fmt.Errorf("policy %q: %w", ref.Policy, err)
|
||||
}
|
||||
if seen[ref.Policy] {
|
||||
return model.Profile{}, fmt.Errorf("policy %q occurs more than once", ref.Policy)
|
||||
}
|
||||
seen[ref.Policy] = true
|
||||
p := s.catalog.Policies[ref.Policy]
|
||||
if p == nil {
|
||||
return model.Profile{}, fmt.Errorf("policy %q: %w", ref.Policy, ErrNotFound)
|
||||
}
|
||||
if ref.Version == "" {
|
||||
ref.Version = "latest"
|
||||
}
|
||||
if ref.Version != "latest" && !hasVersion(p, ref.Version) {
|
||||
return model.Profile{}, fmt.Errorf("policy %q version %q: %w", ref.Policy, ref.Version, ErrNotFound)
|
||||
}
|
||||
}
|
||||
cp := append([]model.ProfilePolicy(nil), refs...)
|
||||
for i := range cp {
|
||||
if cp[i].Version == "" {
|
||||
cp[i].Version = "latest"
|
||||
}
|
||||
}
|
||||
profile := &model.Profile{Name: name, Policies: cp, UpdatedAt: now.UTC()}
|
||||
s.catalog.Profiles[name] = profile
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return model.Profile{}, err
|
||||
}
|
||||
return *profile, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListProfiles() []model.Profile {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]model.Profile, 0, len(s.catalog.Profiles))
|
||||
for _, p := range s.catalog.Profiles {
|
||||
out = append(out, model.Profile{Name: p.Name, Policies: append([]model.ProfilePolicy(nil), p.Policies...), UpdatedAt: p.UpdatedAt})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) ResolveManifest(profileName string) (model.Manifest, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
profile := s.catalog.Profiles[profileName]
|
||||
if profile == nil {
|
||||
return model.Manifest{}, ErrNotFound
|
||||
}
|
||||
manifest := model.Manifest{Profile: profile.Name, ProfileUpdated: profile.UpdatedAt}
|
||||
for _, ref := range profile.Policies {
|
||||
policy := s.catalog.Policies[ref.Policy]
|
||||
if policy == nil || len(policy.Versions) == 0 {
|
||||
return model.Manifest{}, fmt.Errorf("policy %q has no versions", ref.Policy)
|
||||
}
|
||||
var selected model.PolicyVersion
|
||||
if ref.Version == "latest" || ref.Version == "" {
|
||||
selected = policy.Versions[len(policy.Versions)-1]
|
||||
} else {
|
||||
found := false
|
||||
for _, v := range policy.Versions {
|
||||
if v.Version == ref.Version {
|
||||
selected = v
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return model.Manifest{}, fmt.Errorf("policy %q version %q missing", ref.Policy, ref.Version)
|
||||
}
|
||||
}
|
||||
manifest.Policies = append(manifest.Policies, model.ResolvedPolicy{
|
||||
Name: ref.Policy, Version: selected.Version, SHA256: selected.ArtifactHash,
|
||||
SemanticHash: selected.SemanticHash, Size: selected.Size,
|
||||
DownloadURL: fmt.Sprintf("/api/v1/artifacts/%s/%s", ref.Policy, selected.Version),
|
||||
})
|
||||
}
|
||||
gen, err := generationHash(manifest)
|
||||
if err != nil {
|
||||
return model.Manifest{}, err
|
||||
}
|
||||
manifest.Generation = gen
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func generationHash(m model.Manifest) (string, error) {
|
||||
m.Generation = ""
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:]), nil
|
||||
}
|
||||
|
||||
func (s *Store) Artifact(name, version string) (model.PolicyVersion, string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
p := s.catalog.Policies[name]
|
||||
if p == nil {
|
||||
return model.PolicyVersion{}, "", ErrNotFound
|
||||
}
|
||||
for _, v := range p.Versions {
|
||||
if v.Version == version {
|
||||
return v, filepath.Join(s.root, filepath.FromSlash(v.ArtifactPath)), nil
|
||||
}
|
||||
}
|
||||
return model.PolicyVersion{}, "", ErrNotFound
|
||||
}
|
||||
|
||||
func (s *Store) PutClientReport(report model.ClientReport) error {
|
||||
if report.ClientID == "" {
|
||||
return errors.New("client_id is required")
|
||||
}
|
||||
if len(report.ClientID) > 128 {
|
||||
return errors.New("client_id is too long")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.catalog.Clients[report.ClientID] = report
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) ListClientReports() []model.ClientReport {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]model.ClientReport, 0, len(s.catalog.Clients))
|
||||
for _, r := range s.catalog.Clients {
|
||||
out = append(out, r)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ReportedAt.After(out[j].ReportedAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) DeletePolicy(name string) error {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
policy := s.catalog.Policies[name]
|
||||
if policy == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
for _, profile := range s.catalog.Profiles {
|
||||
for _, ref := range profile.Policies {
|
||||
if ref.Policy == name {
|
||||
return fmt.Errorf("policy %q is used by profile %q: %w", name, profile.Name, ErrConflict)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
artifactDir := filepath.Join(s.root, "artifacts", name)
|
||||
tomb := artifactDir + fmt.Sprintf(".delete-%d", time.Now().UnixNano())
|
||||
renamed := false
|
||||
if _, err := os.Stat(artifactDir); err == nil {
|
||||
if err := os.Rename(artifactDir, tomb); err != nil {
|
||||
return err
|
||||
}
|
||||
renamed = true
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(s.catalog.Policies, name)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
s.catalog.Policies[name] = policy
|
||||
if renamed {
|
||||
_ = os.Rename(tomb, artifactDir)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if renamed {
|
||||
_ = os.RemoveAll(tomb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePolicyVersion(name, version string) error {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateName(version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
policy := s.catalog.Policies[name]
|
||||
if policy == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if len(policy.Versions) <= 1 {
|
||||
return fmt.Errorf("cannot delete the last version of policy %q; delete the policy instead: %w", name, ErrConflict)
|
||||
}
|
||||
index := -1
|
||||
var selected model.PolicyVersion
|
||||
for i, candidate := range policy.Versions {
|
||||
if candidate.Version == version {
|
||||
index = i
|
||||
selected = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
for _, profile := range s.catalog.Profiles {
|
||||
for _, ref := range profile.Policies {
|
||||
if ref.Policy == name && ref.Version == version {
|
||||
return fmt.Errorf("policy %q version %q is pinned by profile %q: %w", name, version, profile.Name, ErrConflict)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
artifactPath := filepath.Join(s.root, filepath.FromSlash(selected.ArtifactPath))
|
||||
tomb := artifactPath + fmt.Sprintf(".delete-%d", time.Now().UnixNano())
|
||||
renamed := false
|
||||
if _, err := os.Stat(artifactPath); err == nil {
|
||||
if err := os.Rename(artifactPath, tomb); err != nil {
|
||||
return err
|
||||
}
|
||||
renamed = true
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
original := append([]model.PolicyVersion(nil), policy.Versions...)
|
||||
policy.Versions = append(policy.Versions[:index], policy.Versions[index+1:]...)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
policy.Versions = original
|
||||
if renamed {
|
||||
_ = os.Rename(tomb, artifactPath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if renamed {
|
||||
_ = os.Remove(tomb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProfile(name string) error {
|
||||
if err := ValidateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
profile := s.catalog.Profiles[name]
|
||||
if profile == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.catalog.Profiles, name)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
s.catalog.Profiles[name] = profile
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteClientReport(clientID string) error {
|
||||
if strings.TrimSpace(clientID) == "" || len(clientID) > 128 {
|
||||
return errors.New("invalid client id")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
report, ok := s.catalog.Clients[clientID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.catalog.Clients, clientID)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
s.catalog.Clients[clientID] = report
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasVersion(p *model.Policy, version string) bool {
|
||||
for _, v := range p.Versions {
|
||||
if v.Version == version {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
b, err := json.MarshalIndent(s.catalog, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filename := filepath.Join(s.root, "catalog.json")
|
||||
tmp := filename + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, filename)
|
||||
}
|
||||
|
||||
func copyFileAtomic(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
tmp := dst + ".tmp"
|
||||
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
syncErr := out.Sync()
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return copyErr
|
||||
}
|
||||
if syncErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return syncErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return closeErr
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gpo-distributor/internal/bundle"
|
||||
"gpo-distributor/internal/model"
|
||||
)
|
||||
|
||||
func TestResolveLatest(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "x.zip")
|
||||
if err := os.WriteFile(zipPath, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i, hash := range []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} {
|
||||
_, _, err := s.ImportPolicy("baseline", "", zipPath, bundle.Inspection{ArtifactHash: hash, SemanticHash: hash, Size: 1, FileCount: 1, PolicyFiles: 1}, base.Add(time.Duration(i)*time.Second), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := s.SetProfile("servers", []model.ProfilePolicy{{Policy: "baseline", Version: "latest"}}, base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := s.ResolveManifest("servers")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := m.Policies[0].SemanticHash; got[0] != 'b' {
|
||||
t.Fatalf("expected latest, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePolicyVersionAndReferences(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "bundle.zip")
|
||||
if err := os.WriteFile(zipPath, []byte("bundle"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Date(2026, 8, 5, 8, 0, 0, 0, time.UTC)
|
||||
var versions []model.PolicyVersion
|
||||
for i, hash := range []string{
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
} {
|
||||
version, _, err := s.ImportPolicy("baseline", "", zipPath, bundle.Inspection{
|
||||
ArtifactHash: hash,
|
||||
SemanticHash: hash,
|
||||
Size: 6,
|
||||
FileCount: 1,
|
||||
PolicyFiles: 1,
|
||||
}, base.Add(time.Duration(i)*time.Second), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions = append(versions, version)
|
||||
}
|
||||
if _, err := s.SetProfile("servers", []model.ProfilePolicy{{Policy: "baseline", Version: versions[0].Version}}, base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeletePolicyVersion("baseline", versions[0].Version); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected pinned version conflict, got %v", err)
|
||||
}
|
||||
if err := s.DeletePolicy("baseline"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected referenced policy conflict, got %v", err)
|
||||
}
|
||||
if err := s.DeleteProfile("servers"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, artifact, err := s.Artifact("baseline", versions[0].Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DeletePolicyVersion("baseline", versions[0].Version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(artifact); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("artifact still exists or unexpected stat error: %v", err)
|
||||
}
|
||||
if err := s.DeletePolicyVersion("baseline", versions[1].Version); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected last-version conflict, got %v", err)
|
||||
}
|
||||
if err := s.DeletePolicy("baseline"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.GetPolicy("baseline"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected deleted policy to be missing, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user