Files
lgpo-server/internal/agent/agent.go
groot d191b871de
All checks were successful
release-tag / release-image (push) Successful in 1m37s
init
2026-08-05 10:46:09 +02:00

457 lines
14 KiB
Go

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()))
}
}