398 lines
11 KiB
Go
398 lines
11 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/example/sessionguard/internal/auth"
|
|
"github.com/example/sessionguard/internal/config"
|
|
"github.com/example/sessionguard/internal/httpx"
|
|
"github.com/example/sessionguard/internal/model"
|
|
tpl "github.com/example/sessionguard/internal/templates"
|
|
"github.com/example/sessionguard/internal/windowsx"
|
|
)
|
|
|
|
const Version = "0.1.0"
|
|
|
|
type App struct {
|
|
cfg config.Agent
|
|
store stateStore
|
|
mu sync.RWMutex
|
|
state State
|
|
snapshot model.AgentSnapshot
|
|
lastMasterOK time.Time
|
|
masterErr string
|
|
client *masterClient
|
|
}
|
|
|
|
func New(cfg config.Agent) (*App, error) {
|
|
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
if cfg.Policy.Revision == "" {
|
|
cfg.Policy.Revision = newRevision()
|
|
cfg.Policy.UpdatedAt = time.Now().UTC()
|
|
}
|
|
st, err := loadState(statePath(cfg.DataDir), cfg.Policy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a := &App{cfg: cfg, store: stateStore{path: statePath(cfg.DataDir)}, state: st, client: newMasterClient(cfg.MasterURL)}
|
|
return a, nil
|
|
}
|
|
|
|
func newRevision() string { b := make([]byte, 12); _, _ = rand.Read(b); return hex.EncodeToString(b) }
|
|
|
|
func (a *App) Run(ctx context.Context) error {
|
|
go a.worker(ctx)
|
|
return a.serveHTTP(ctx)
|
|
}
|
|
|
|
func (a *App) worker(ctx context.Context) {
|
|
poll := time.NewTicker(time.Duration(max(2, a.policy().Cleanup.PollSeconds)) * time.Second)
|
|
defer poll.Stop()
|
|
hb := time.NewTicker(time.Duration(max(3, a.cfg.HeartbeatSeconds)) * time.Second)
|
|
defer hb.Stop()
|
|
a.tick(ctx)
|
|
a.sendHeartbeat(ctx)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-poll.C:
|
|
a.tick(ctx)
|
|
case <-hb.C:
|
|
a.sendHeartbeat(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) tick(ctx context.Context) {
|
|
sessions, err := windowsx.Sessions()
|
|
if err != nil {
|
|
log.Printf("sessions: %v", err)
|
|
return
|
|
}
|
|
server, err := windowsx.Server()
|
|
if err != nil {
|
|
log.Printf("server info: %v", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
currentBySID := map[string]bool{}
|
|
currentIDs := map[uint32]model.Session{}
|
|
for _, s := range sessions {
|
|
currentIDs[s.ID] = s
|
|
if s.SID != "" {
|
|
currentBySID[s.SID] = true
|
|
delete(a.state.Pending, s.SID)
|
|
}
|
|
}
|
|
|
|
for id, prev := range a.state.LastSessions {
|
|
if _, exists := currentIDs[id]; exists || prev.SID == "" || currentBySID[prev.SID] {
|
|
continue
|
|
}
|
|
if _, exists := a.state.Pending[prev.SID]; exists {
|
|
continue
|
|
}
|
|
if a.excluded(prev) {
|
|
continue
|
|
}
|
|
path, err := windowsx.ProfilePath(prev.SID)
|
|
if err != nil {
|
|
log.Printf("profile path for %s/%s: %v", prev.User, prev.SID, err)
|
|
continue
|
|
}
|
|
if !a.safeProfilePath(path) {
|
|
log.Printf("refusing cleanup outside allowed roots: %s (%s)", path, prev.User)
|
|
continue
|
|
}
|
|
a.state.Pending[prev.SID] = model.CleanupJob{SID: prev.SID, User: displayUser(prev), ProfilePath: path, DueAt: now.Add(time.Duration(a.state.Policy.Cleanup.GraceSeconds) * time.Second)}
|
|
log.Printf("scheduled profile cleanup: %s in %ds", displayUser(prev), a.state.Policy.Cleanup.GraceSeconds)
|
|
}
|
|
|
|
for _, s := range sessions {
|
|
if s.SID == "" || s.User == "" {
|
|
continue
|
|
}
|
|
_, was := a.state.LastSessions[s.ID]
|
|
if !was {
|
|
a.applyTemplatesLocked(s)
|
|
}
|
|
}
|
|
|
|
if a.state.Policy.Cleanup.Enabled {
|
|
a.processCleanupLocked(now, currentBySID)
|
|
}
|
|
a.state.LastSessions = currentIDs
|
|
a.snapshot = model.AgentSnapshot{ProtocolVersion: model.ProtocolVersion, AgentID: a.state.AgentID, Server: server, Sessions: sessions, PendingCleanup: pendingSlice(a.state.Pending), PolicyRevision: a.state.Policy.Revision, AgentVersion: Version, Time: now}
|
|
if err := a.store.save(a.state); err != nil {
|
|
log.Printf("save state: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *App) applyTemplatesLocked(s model.Session) {
|
|
path, err := windowsx.ProfilePath(s.SID)
|
|
if err != nil {
|
|
log.Printf("templates profile %s: %v", displayUser(s), err)
|
|
return
|
|
}
|
|
for _, item := range a.state.Policy.Templates {
|
|
changed, err := tpl.Apply(path, item)
|
|
if err != nil {
|
|
log.Printf("template %s for %s: %v", item.ID, displayUser(s), err)
|
|
continue
|
|
}
|
|
if changed {
|
|
log.Printf("template %s applied for %s", item.ID, displayUser(s))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) processCleanupLocked(now time.Time, active map[string]bool) {
|
|
p := a.state.Policy.Cleanup
|
|
for sid, job := range a.state.Pending {
|
|
if active[sid] {
|
|
delete(a.state.Pending, sid)
|
|
continue
|
|
}
|
|
if now.Before(job.DueAt) {
|
|
continue
|
|
}
|
|
if !a.safeProfilePath(job.ProfilePath) {
|
|
job.LastError = "profile path is outside allowed roots"
|
|
job.DueAt = now.Add(time.Duration(p.RetrySeconds) * time.Second)
|
|
a.state.Pending[sid] = job
|
|
continue
|
|
}
|
|
if p.DryRun {
|
|
job.LastError = "dry-run: deletion skipped"
|
|
job.DueAt = now.Add(time.Duration(p.RetrySeconds) * time.Second)
|
|
a.state.Pending[sid] = job
|
|
log.Printf("dry-run profile deletion: %s (%s)", job.User, job.ProfilePath)
|
|
continue
|
|
}
|
|
if err := windowsx.DeleteProfile(sid); err != nil {
|
|
job.Attempts++
|
|
job.LastError = err.Error()
|
|
job.DueAt = now.Add(time.Duration(p.RetrySeconds) * time.Second)
|
|
a.state.Pending[sid] = job
|
|
log.Printf("delete profile %s: %v", job.User, err)
|
|
continue
|
|
}
|
|
delete(a.state.Pending, sid)
|
|
log.Printf("deleted profile: %s (%s)", job.User, job.ProfilePath)
|
|
}
|
|
}
|
|
|
|
func (a *App) excluded(s model.Session) bool {
|
|
p := a.state.Policy.Cleanup
|
|
for _, u := range p.ExcludeUsers {
|
|
if strings.EqualFold(strings.TrimSpace(u), s.User) || strings.EqualFold(strings.TrimSpace(u), displayUser(s)) {
|
|
return true
|
|
}
|
|
}
|
|
for _, x := range p.ExcludeSIDs {
|
|
if strings.EqualFold(s.SID, x) || strings.HasPrefix(strings.ToUpper(s.SID), strings.ToUpper(x)+"-") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *App) safeProfilePath(path string) bool {
|
|
clean, err := filepath.Abs(filepath.Clean(path))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, root := range a.state.Policy.Cleanup.AllowedProfileRoots {
|
|
r, err := filepath.Abs(filepath.Clean(root))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
c, rr := strings.ToLower(clean), strings.ToLower(r)
|
|
if c == rr || strings.HasPrefix(c, rr+string(os.PathSeparator)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func displayUser(s model.Session) string {
|
|
if s.Domain != "" {
|
|
return s.Domain + `\` + s.User
|
|
}
|
|
return s.User
|
|
}
|
|
func pendingSlice(m map[string]model.CleanupJob) []model.CleanupJob {
|
|
out := make([]model.CleanupJob, 0, len(m))
|
|
for _, v := range m {
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
func (a *App) policy() model.Policy { a.mu.RLock(); defer a.mu.RUnlock(); return a.state.Policy }
|
|
|
|
func (a *App) sendHeartbeat(ctx context.Context) {
|
|
if a.cfg.MasterURL == "" {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
if a.state.AgentID == "" || a.state.AgentToken == "" {
|
|
server, _ := windowsx.Server()
|
|
mid, _ := windowsx.MachineID()
|
|
resp, err := a.client.enroll(ctx, model.EnrollRequest{EnrollmentToken: a.cfg.EnrollmentToken, Name: server.Hostname, MachineID: mid})
|
|
if err != nil {
|
|
a.masterErr = err.Error()
|
|
a.mu.Unlock()
|
|
log.Printf("master enroll: %v", err)
|
|
return
|
|
}
|
|
a.state.AgentID, a.state.AgentToken = resp.AgentID, resp.Token
|
|
_ = a.store.save(a.state)
|
|
}
|
|
id, token, snap := a.state.AgentID, a.state.AgentToken, a.snapshot
|
|
snap.AgentID = id
|
|
a.mu.Unlock()
|
|
resp, err := a.client.heartbeat(ctx, id, token, snap)
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if err != nil {
|
|
a.masterErr = err.Error()
|
|
log.Printf("master heartbeat: %v", err)
|
|
return
|
|
}
|
|
a.lastMasterOK = time.Now().UTC()
|
|
a.masterErr = ""
|
|
if resp.DesiredPolicy != nil && resp.DesiredPolicy.Revision != "" && resp.DesiredPolicy.Revision != a.state.Policy.Revision {
|
|
a.state.Policy = *resp.DesiredPolicy
|
|
_ = a.store.save(a.state)
|
|
log.Printf("applied master policy revision %s", a.state.Policy.Revision)
|
|
for _, s := range a.state.LastSessions {
|
|
if s.SID != "" && s.User != "" {
|
|
a.applyTemplatesLocked(s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) serveHTTP(ctx context.Context) error {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, 200, map[string]any{"ok": true}) })
|
|
var am *auth.Manager
|
|
if a.cfg.OIDC.Issuer != "" {
|
|
var err error
|
|
am, err = auth.New(ctx, a.cfg.OIDC)
|
|
if err != nil {
|
|
log.Printf("local OIDC unavailable: %v", err)
|
|
}
|
|
}
|
|
if am != nil {
|
|
am.Register(mux)
|
|
}
|
|
secure := func(h http.Handler) http.Handler {
|
|
if am == nil {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "OIDC is not configured or unavailable", http.StatusServiceUnavailable)
|
|
})
|
|
}
|
|
return am.Require(h)
|
|
}
|
|
mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
_, _ = fmt.Fprint(w, agentJS)
|
|
})
|
|
mux.Handle("GET /", secure(http.HandlerFunc(a.agentPage)))
|
|
mux.Handle("GET /api/v1/status", secure(http.HandlerFunc(a.statusAPI)))
|
|
mux.Handle("GET /api/v1/policy", secure(http.HandlerFunc(a.policyAPI)))
|
|
mux.Handle("PUT /api/v1/policy", secure(http.HandlerFunc(a.policyAPI)))
|
|
server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second}
|
|
go func() {
|
|
<-ctx.Done()
|
|
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = server.Shutdown(c)
|
|
}()
|
|
log.Printf("agent web listening on %s", a.cfg.Listen)
|
|
err := server.ListenAndServe()
|
|
if err == http.ErrServerClosed {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (a *App) statusAPI(w http.ResponseWriter, r *http.Request) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
httpx.JSON(w, 200, map[string]any{"snapshot": a.snapshot, "last_master_ok": a.lastMasterOK, "master_error": a.masterErr, "master_url": a.cfg.MasterURL})
|
|
}
|
|
func (a *App) policyAPI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
httpx.JSON(w, 200, a.state.Policy)
|
|
return
|
|
}
|
|
if !httpx.SameOrigin(r) {
|
|
httpx.Error(w, 403, "cross-origin request rejected")
|
|
return
|
|
}
|
|
var p model.Policy
|
|
if err := httpx.DecodeJSON(r, &p, 2<<20); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
p.Revision = newRevision()
|
|
p.UpdatedAt = time.Now().UTC()
|
|
if p.Cleanup.GraceSeconds < 1 || p.Cleanup.PollSeconds < 2 {
|
|
httpx.Error(w, 400, "invalid cleanup timing")
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
a.state.Policy = p
|
|
_ = a.store.save(a.state)
|
|
sessions := a.state.LastSessions
|
|
a.mu.Unlock()
|
|
for _, s := range sessions {
|
|
if s.SID != "" && s.User != "" {
|
|
a.mu.Lock()
|
|
a.applyTemplatesLocked(s)
|
|
a.mu.Unlock()
|
|
}
|
|
}
|
|
httpx.JSON(w, 200, p)
|
|
}
|
|
|
|
func 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("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "same-origin")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'self' data:")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (a *App) agentPage(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = fmt.Fprint(w, agentHTML)
|
|
}
|