1807 lines
60 KiB
Go
1807 lines
60 KiB
Go
package master
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"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"
|
|
)
|
|
|
|
const Version = "0.5.2"
|
|
|
|
type App struct {
|
|
cfg config.Master
|
|
store *store
|
|
auth *auth.Manager
|
|
access *auth.AccessManager
|
|
http *http.Client
|
|
}
|
|
|
|
func New(ctx context.Context, cfg config.Master) (*App, error) {
|
|
s, err := newStore(ctx, cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a, err := auth.New(ctx, cfg.OIDC)
|
|
if err != nil {
|
|
_ = s.close()
|
|
return nil, fmt.Errorf("OIDC: %w", err)
|
|
}
|
|
access, err := auth.NewAccess(ctx, cfg.AccessAuth, authSessionStore{s: s})
|
|
if err != nil {
|
|
_ = s.close()
|
|
return nil, fmt.Errorf("access auth: %w", err)
|
|
}
|
|
return &App{cfg: cfg, store: s, auth: a, access: access, http: &http.Client{Timeout: 8 * time.Second}}, nil
|
|
}
|
|
|
|
func (a *App) Run(ctx context.Context) error {
|
|
mux := http.NewServeMux()
|
|
a.auth.Register(mux)
|
|
if a.access != nil {
|
|
a.access.Register(mux)
|
|
}
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
httpx.JSON(w, 200, map[string]any{"ok": true, "version": Version, "store": a.store.kind()})
|
|
})
|
|
mux.HandleFunc("GET /metrics", a.metrics)
|
|
mux.HandleFunc("POST /api/v1/agents/enroll", a.enroll)
|
|
mux.HandleFunc("POST /api/v1/agents/heartbeat", a.heartbeat)
|
|
mux.HandleFunc("POST /api/v1/broker/resolve", a.brokerResolve)
|
|
mux.HandleFunc("POST /api/v1/broker/tokens", a.brokerTokens)
|
|
mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
|
_, _ = fmt.Fprint(w, masterJS)
|
|
})
|
|
mux.Handle("GET /{$}", a.auth.Require(http.HandlerFunc(a.masterPage)))
|
|
mux.Handle("GET /api/v1/me", a.auth.Require(http.HandlerFunc(a.me)))
|
|
mux.Handle("GET /api/v1/dashboard", a.auth.Require(http.HandlerFunc(a.dashboard)))
|
|
mux.Handle("GET /api/v1/agents/{id}", a.auth.Require(http.HandlerFunc(a.agentDetail)))
|
|
mux.Handle("PUT /api/v1/agents/{id}/policy", a.auth.Require(a.require("policy", http.HandlerFunc(a.policy))))
|
|
mux.Handle("POST /api/v1/agents/{id}/policy/rollback/{revision}", a.auth.Require(a.require("policy", http.HandlerFunc(a.policyRollback))))
|
|
mux.Handle("PUT /api/v1/policy/all", a.auth.Require(a.require("policy", http.HandlerFunc(a.policyAll))))
|
|
mux.Handle("POST /api/v1/policy/global/rollback/{revision}", a.auth.Require(a.require("policy", http.HandlerFunc(a.globalPolicyRollback))))
|
|
mux.Handle("PUT /api/v1/farms/{id}/policy", a.auth.Require(a.require("policy", http.HandlerFunc(a.farmPolicy))))
|
|
mux.Handle("POST /api/v1/farms/{id}/policy/rollback/{revision}", a.auth.Require(a.require("policy", http.HandlerFunc(a.farmPolicyRollback))))
|
|
mux.Handle("PATCH /api/v1/agents/{id}/control", a.auth.Require(a.require("maintenance", http.HandlerFunc(a.agentControl))))
|
|
mux.Handle("POST /api/v1/agents/{id}/sessions/{session}/action", a.auth.Require(a.require("session", http.HandlerFunc(a.sessionAction))))
|
|
mux.Handle("POST /api/v1/agents/{id}/sessions/bulk", a.auth.Require(a.require("session", http.HandlerFunc(a.sessionBulkAction))))
|
|
mux.Handle("POST /api/v1/agents/{id}/processes/{pid}/kill", a.auth.Require(a.require("process", http.HandlerFunc(a.processKill))))
|
|
mux.Handle("GET /api/v1/audit", a.auth.Require(a.require("audit", http.HandlerFunc(a.audit))))
|
|
mux.Handle("GET /api/v1/history", a.auth.Require(http.HandlerFunc(a.history)))
|
|
mux.Handle("GET /api/v1/policy/history", a.auth.Require(a.require("policy", http.HandlerFunc(a.policyHistory))))
|
|
mux.Handle("GET /api/v1/farms", a.auth.Require(http.HandlerFunc(a.farms)))
|
|
mux.Handle("POST /api/v1/farms", a.auth.Require(a.require("manage", http.HandlerFunc(a.farmCreate))))
|
|
mux.Handle("PUT /api/v1/farms/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.farmUpdate))))
|
|
mux.Handle("DELETE /api/v1/farms/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.farmDelete))))
|
|
mux.Handle("GET /api/v1/resources", a.auth.Require(http.HandlerFunc(a.resources)))
|
|
mux.Handle("POST /api/v1/resources", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceCreate))))
|
|
mux.Handle("PUT /api/v1/resources/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceUpdate))))
|
|
mux.Handle("DELETE /api/v1/resources/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.resourceDelete))))
|
|
mux.Handle("GET /api/v1/alerts", a.auth.Require(http.HandlerFunc(a.alerts)))
|
|
mux.Handle("GET /api/v1/leases", a.auth.Require(http.HandlerFunc(a.leases)))
|
|
mux.Handle("GET /api/v1/access/sessions", a.auth.Require(a.require("manage", http.HandlerFunc(a.accessSessions))))
|
|
mux.Handle("DELETE /api/v1/access/sessions/{id}", a.auth.Require(a.require("manage", http.HandlerFunc(a.accessSessionRevoke))))
|
|
server := &http.Server{Addr: a.cfg.Listen, Handler: securityHeaders(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 90 * time.Second}
|
|
go a.monitor(ctx)
|
|
go func() {
|
|
<-ctx.Done()
|
|
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = server.Shutdown(c)
|
|
_ = a.store.close()
|
|
}()
|
|
log.Printf("master %s listening on %s (store=%s)", Version, a.cfg.Listen, a.store.kind())
|
|
err := server.ListenAndServe()
|
|
if err == http.ErrServerClosed {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (a *App) enroll(w http.ResponseWriter, r *http.Request) {
|
|
var req model.EnrollRequest
|
|
if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
if !constantEqual(req.EnrollmentToken, a.cfg.EnrollmentToken) || strings.TrimSpace(req.MachineID) == "" {
|
|
httpx.Error(w, 401, "invalid enrollment")
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
token, id := randomToken(32), randomToken(16)
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
for oldID, rec := range a.store.data.Agents {
|
|
if rec.MachineID == req.MachineID {
|
|
id = oldID
|
|
break
|
|
}
|
|
}
|
|
rec := a.store.data.Agents[id]
|
|
newEnrollment := rec.EnrolledAt.IsZero()
|
|
rec.ID = id
|
|
rec.Name = req.Name
|
|
rec.MachineID = req.MachineID
|
|
rec.TokenHash = hashToken(token)
|
|
if newEnrollment {
|
|
rec.EnrolledAt = now
|
|
}
|
|
if rec.Tags == nil {
|
|
rec.Tags = map[string]string{}
|
|
}
|
|
if rec.MaintenanceMode == "" {
|
|
rec.MaintenanceMode = "online"
|
|
}
|
|
a.store.data.Agents[id] = rec
|
|
action := "agent_reenroll"
|
|
if newEnrollment {
|
|
action = "agent_enroll"
|
|
}
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "agent-bootstrap", Action: action, Target: req.Name, Result: "success", Details: req.MachineID})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, model.EnrollResponse{AgentID: id, Token: token})
|
|
}
|
|
|
|
func (a *App) heartbeat(w http.ResponseWriter, r *http.Request) {
|
|
id := r.Header.Get("X-Agent-ID")
|
|
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if id == "" || token == "" {
|
|
httpx.Error(w, 401, "missing agent credentials")
|
|
return
|
|
}
|
|
var snap model.AgentSnapshot
|
|
if err := httpx.DecodeJSON(r, &snap, 8<<20); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
if snap.ProtocolVersion != model.ProtocolVersion {
|
|
httpx.Error(w, 409, "protocol version mismatch")
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
var notify []model.Alert
|
|
a.store.mu.Lock()
|
|
rec, ok := a.store.data.Agents[id]
|
|
if !ok || !constantEqual(hashToken(token), rec.TokenHash) {
|
|
a.store.mu.Unlock()
|
|
httpx.Error(w, 401, "invalid agent credentials")
|
|
return
|
|
}
|
|
previous := rec.Snapshot
|
|
snap.AgentID = id
|
|
rec.LastSeen = now
|
|
rec.Snapshot = snap
|
|
if snap.Server.Hostname != "" {
|
|
rec.Name = snap.Server.Hostname
|
|
}
|
|
a.recordSessionHistoryLocked(rec, previous, snap, now)
|
|
results := map[string]model.CommandResult{}
|
|
for _, res := range snap.CommandResults {
|
|
if res.ID != "" {
|
|
results[res.ID] = res
|
|
}
|
|
}
|
|
pending := make([]model.SessionCommand, 0, len(rec.PendingCommands))
|
|
for _, cmd := range rec.PendingCommands {
|
|
if result, found := results[cmd.ID]; found {
|
|
status, details := "success", ""
|
|
if !result.Success {
|
|
status = "error"
|
|
details = result.Error
|
|
}
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "agent:" + rec.Name, Action: "command_result:" + cmd.Action, Target: commandTarget(rec, cmd), Result: status, Details: details})
|
|
continue
|
|
}
|
|
if !cmd.ExpiresAt.IsZero() && now.After(cmd.ExpiresAt) {
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "system", Action: "command_expired:" + cmd.Action, Target: commandTarget(rec, cmd), Result: "expired"})
|
|
continue
|
|
}
|
|
pending = append(pending, cmd)
|
|
}
|
|
rec.PendingCommands = pending
|
|
if rec.RestartWhenDrained && userSessionCount(snap.Sessions) == 0 && !hasPendingAction(rec.PendingCommands, "restart_server") {
|
|
cmd := model.SessionCommand{ID: randomToken(12), Action: "restart_server", RequestedBy: "system:drain", Message: "SessionGuard restart after drain", CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute)}
|
|
rec.PendingCommands = append(rec.PendingCommands, cmd)
|
|
rec.RestartWhenDrained = false
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: "system", Action: "restart_when_drained", Target: rec.Name, Result: "queued"})
|
|
}
|
|
a.store.data.Agents[id] = rec
|
|
notify = a.evaluateAgentAlertsLocked(rec, now)
|
|
desired := a.effectivePolicyLocked(rec)
|
|
var sendPolicy *model.Policy
|
|
if desired != nil && desired.Revision != snap.PolicyRevision {
|
|
cp := *desired
|
|
sendPolicy = &cp
|
|
}
|
|
desiredRemoteApps := a.desiredRemoteAppsLocked(id, rec)
|
|
commands := append([]model.SessionCommand(nil), rec.PendingCommands...)
|
|
if err := a.store.saveLocked(); err != nil {
|
|
a.store.mu.Unlock()
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
a.store.mu.Unlock()
|
|
for _, al := range notify {
|
|
a.notifyAlert(al)
|
|
}
|
|
httpx.JSON(w, 200, model.HeartbeatResponse{DesiredPolicy: sendPolicy, DesiredRemoteApps: desiredRemoteApps, Commands: commands, ServerTime: now})
|
|
}
|
|
|
|
func (a *App) recordSessionHistoryLocked(rec model.AgentRecord, old, new model.AgentSnapshot, now time.Time) {
|
|
om := map[uint32]model.Session{}
|
|
nm := map[uint32]model.Session{}
|
|
for _, s := range old.Sessions {
|
|
if s.User != "" {
|
|
om[s.ID] = s
|
|
}
|
|
}
|
|
for _, s := range new.Sessions {
|
|
if s.User != "" {
|
|
nm[s.ID] = s
|
|
}
|
|
}
|
|
for id, s := range nm {
|
|
prev, exists := om[id]
|
|
event := ""
|
|
if !exists {
|
|
event = "logon"
|
|
} else if prev.State != s.State {
|
|
event = strings.ToLower(s.State)
|
|
}
|
|
if event != "" {
|
|
a.store.appendHistoryLocked(model.SessionHistoryEvent{Time: now, AgentID: rec.ID, Hostname: rec.Name, SessionID: id, User: displaySessionUser(s), SID: s.SID, Event: event, State: s.State, ClientName: s.ClientName}, a.cfg.HistoryLimit)
|
|
}
|
|
}
|
|
for id, s := range om {
|
|
if _, exists := nm[id]; !exists {
|
|
a.store.appendHistoryLocked(model.SessionHistoryEvent{Time: now, AgentID: rec.ID, Hostname: rec.Name, SessionID: id, User: displaySessionUser(s), SID: s.SID, Event: "logoff", State: s.State, ClientName: s.ClientName}, a.cfg.HistoryLimit)
|
|
}
|
|
}
|
|
for id, telemetry := range new.Telemetry {
|
|
oldTelemetry := old.Telemetry[id]
|
|
if telemetry.ObservedLogonMS > 0 && oldTelemetry.ObservedLogonMS == 0 {
|
|
details := fmt.Sprintf("logon=%dms restore=%dms", telemetry.ObservedLogonMS, telemetry.RestoreDurationMS)
|
|
a.store.appendHistoryLocked(model.SessionHistoryEvent{Time: now, AgentID: rec.ID, Hostname: rec.Name, SessionID: id, User: telemetry.User, SID: telemetry.SID, Event: "logon_ready", State: "Active", Details: details}, a.cfg.HistoryLimit)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) effectivePolicyLocked(rec model.AgentRecord) *model.Policy {
|
|
if rec.DesiredPolicy != nil {
|
|
return rec.DesiredPolicy
|
|
}
|
|
|
|
// Explicit per-agent farm assignments have precedence and keep their
|
|
// configured order. This makes policy precedence predictable when an agent
|
|
// intentionally belongs to multiple farms.
|
|
for _, fid := range rec.FarmIDs {
|
|
if f, ok := a.store.data.Farms[fid]; ok && f.Enabled && f.Policy != nil {
|
|
return f.Policy
|
|
}
|
|
}
|
|
|
|
// Farms may also select agents centrally through AgentIDs or RequiredTags.
|
|
// Evaluate these deterministically so a server receives the same policy
|
|
// after every master restart.
|
|
ids := make([]string, 0, len(a.store.data.Farms))
|
|
for id := range a.store.data.Farms {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
for _, fid := range ids {
|
|
f := a.store.data.Farms[fid]
|
|
if !f.Enabled || f.Policy == nil {
|
|
continue
|
|
}
|
|
if contains(f.AgentIDs, rec.ID) || (len(f.RequiredTags) > 0 && tagsMatch(rec.Tags, f.RequiredTags)) {
|
|
return f.Policy
|
|
}
|
|
}
|
|
return a.store.data.GlobalPolicy
|
|
}
|
|
|
|
func (a *App) brokerResolve(w http.ResponseWriter, r *http.Request) {
|
|
if !a.cfg.Broker.Enabled {
|
|
httpx.Error(w, 404, "broker disabled")
|
|
return
|
|
}
|
|
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !constantEqual(token, a.cfg.Broker.APIKey) {
|
|
httpx.Error(w, 401, "invalid broker credential")
|
|
return
|
|
}
|
|
var req model.BrokerRequest
|
|
if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Username) == "" {
|
|
httpx.Error(w, 400, "username is required")
|
|
return
|
|
}
|
|
resp, err := a.resolveBroker(req)
|
|
if err != nil {
|
|
httpx.Error(w, 503, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, resp)
|
|
}
|
|
|
|
func (a *App) brokerTokens(w http.ResponseWriter, r *http.Request) {
|
|
if !a.cfg.Broker.Enabled {
|
|
httpx.Error(w, 404, "broker disabled")
|
|
return
|
|
}
|
|
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !constantEqual(token, a.cfg.Broker.APIKey) {
|
|
httpx.Error(w, 401, "invalid broker credential")
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
httpx.Error(w, 400, "invalid form")
|
|
return
|
|
}
|
|
req := model.BrokerRequest{
|
|
Username: r.FormValue("username"),
|
|
ConnectionID: r.FormValue("connection_id"),
|
|
ConnectionName: r.FormValue("connection_name"),
|
|
ResourceID: r.FormValue("resource_id"),
|
|
FarmID: r.FormValue("farm_id"),
|
|
}
|
|
if strings.TrimSpace(req.Username) == "" {
|
|
httpx.Error(w, 400, "username is required")
|
|
return
|
|
}
|
|
// The Guacamole extension decorates every connection. Only connections
|
|
// explicitly mapped as SessionGuard Resources should therefore invoke the
|
|
// broker. Returning an empty token set leaves unrelated/static Guacamole
|
|
// connections untouched and prevents a broker outage from breaking them.
|
|
if !a.hasMappedResource(req) {
|
|
w.Header().Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
resp, err := a.resolveBroker(req)
|
|
if err != nil {
|
|
httpx.Error(w, 503, err.Error())
|
|
return
|
|
}
|
|
values := url.Values{}
|
|
for k, v := range resp.Tokens {
|
|
values.Set(k, v)
|
|
}
|
|
values.Set("SESSIONGUARD_REASON", resp.Reason)
|
|
values.Set("SESSIONGUARD_FARM_ID", resp.FarmID)
|
|
w.Header().Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
|
_, _ = fmt.Fprint(w, values.Encode())
|
|
}
|
|
|
|
func (a *App) hasMappedResource(req model.BrokerRequest) bool {
|
|
a.store.mu.RLock()
|
|
defer a.store.mu.RUnlock()
|
|
_, ok := a.findResourceLocked(req)
|
|
return ok
|
|
}
|
|
|
|
func (a *App) findResourceLocked(req model.BrokerRequest) (*model.Resource, bool) {
|
|
// Prefer stable explicit identifiers over names. Guacamole sends both
|
|
// connection ID and name; deterministic precedence prevents a bad mapping
|
|
// from being selected according to Go map iteration order.
|
|
if req.ResourceID != "" {
|
|
if res, ok := a.store.data.Resources[req.ResourceID]; ok && res.Enabled {
|
|
cp := res
|
|
return &cp, true
|
|
}
|
|
}
|
|
if req.ConnectionID != "" {
|
|
for _, res := range a.store.data.Resources {
|
|
if res.Enabled && res.GuacamoleConnectionID != "" && res.GuacamoleConnectionID == req.ConnectionID {
|
|
cp := res
|
|
return &cp, true
|
|
}
|
|
}
|
|
}
|
|
if req.ConnectionName != "" {
|
|
for _, res := range a.store.data.Resources {
|
|
if res.Enabled && res.GuacamoleConnectionName != "" && strings.EqualFold(res.GuacamoleConnectionName, req.ConnectionName) {
|
|
cp := res
|
|
return &cp, true
|
|
}
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (a *App) resolveBroker(req model.BrokerRequest) (model.BrokerResponse, error) {
|
|
now := time.Now().UTC()
|
|
userKey := normalizeUser(req.Username)
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
resource, _ := a.findResourceLocked(req)
|
|
farmID := req.FarmID
|
|
if resource != nil {
|
|
farmID = resource.FarmID
|
|
}
|
|
if farmID == "" {
|
|
farmID = a.cfg.Broker.DefaultFarmID
|
|
}
|
|
if farmID != "" {
|
|
if f, ok := a.store.data.Farms[farmID]; !ok || !f.Enabled {
|
|
return model.BrokerResponse{}, fmt.Errorf("farm %q is not available", farmID)
|
|
}
|
|
}
|
|
leaseKey := a.brokerLeaseKey(userKey, farmID, resourceID(resource))
|
|
|
|
// Existing RDS session wins for the requested farm, including on a draining host.
|
|
// Maintenance hosts are never selected. This provides Citrix-like reconnect affinity.
|
|
if a.cfg.Broker.ReconnectExisting {
|
|
for id, rec := range a.store.data.Agents {
|
|
if rec.MaintenanceMode == "maintenance" || !agentOnline(rec, now, a.cfg.OfflineAfterSeconds) || !a.agentInFarmLocked(id, rec, farmID) || !agentResourceReady(rec, resource) {
|
|
continue
|
|
}
|
|
for _, sess := range rec.Snapshot.Sessions {
|
|
if brokerSessionState(sess.State) && sessionMatchesUser(sess, userKey) {
|
|
lease := a.putLeaseLocked(leaseKey, userKey, id, farmID, resourceID(resource), "existing-session", now)
|
|
if err := a.store.saveLocked(); err != nil {
|
|
return model.BrokerResponse{}, err
|
|
}
|
|
return brokerResponse(rec, farmID, resource, true, "existing-session", lease), nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if lease, ok := a.store.data.Leases[leaseKey]; ok && now.Before(lease.ExpiresAt) {
|
|
if rec, found := a.store.data.Agents[lease.AgentID]; found && rec.MaintenanceMode != "maintenance" && agentOnline(rec, now, a.cfg.OfflineAfterSeconds) && a.agentInFarmLocked(lease.AgentID, rec, farmID) && agentResourceReady(rec, resource) {
|
|
lease.ExpiresAt = now.Add(time.Duration(a.cfg.Broker.LeaseSeconds) * time.Second)
|
|
a.store.data.Leases[leaseKey] = lease
|
|
if err := a.store.saveLocked(); err != nil {
|
|
return model.BrokerResponse{}, err
|
|
}
|
|
return brokerResponse(rec, farmID, resource, true, "existing-lease", lease), nil
|
|
}
|
|
}
|
|
candidates := a.farmCandidatesLocked(farmID, resource, now)
|
|
if len(candidates) == 0 {
|
|
return model.BrokerResponse{}, fmt.Errorf("no healthy online server is available for farm %q", farmID)
|
|
}
|
|
sort.Slice(candidates, func(i, j int) bool { return brokerScore(candidates[i]) > brokerScore(candidates[j]) })
|
|
chosen := candidates[0]
|
|
lease := a.putLeaseLocked(leaseKey, userKey, chosen.ID, farmID, resourceID(resource), "load-balance", now)
|
|
if err := a.store.saveLocked(); err != nil {
|
|
return model.BrokerResponse{}, err
|
|
}
|
|
return brokerResponse(chosen, farmID, resource, false, "load-balance", lease), nil
|
|
}
|
|
|
|
func (a *App) brokerLeaseKey(user, farm, resource string) string {
|
|
if a.cfg.Broker.SingleSession {
|
|
return user
|
|
}
|
|
return user + "|" + farm + "|" + resource
|
|
}
|
|
|
|
func brokerSessionState(state string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(state)) {
|
|
case "active", "connected", "disconnected":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (a *App) agentInFarmLocked(id string, rec model.AgentRecord, farmID string) bool {
|
|
if farmID == "" {
|
|
return true
|
|
}
|
|
f, ok := a.store.data.Farms[farmID]
|
|
if !ok || !f.Enabled {
|
|
return false
|
|
}
|
|
if contains(f.AgentIDs, id) || contains(rec.FarmIDs, farmID) {
|
|
return true
|
|
}
|
|
return len(f.RequiredTags) > 0 && tagsMatch(rec.Tags, f.RequiredTags)
|
|
}
|
|
|
|
func (a *App) farmCandidatesLocked(farmID string, resource *model.Resource, now time.Time) []model.AgentRecord {
|
|
if farmID != "" {
|
|
if f, ok := a.store.data.Farms[farmID]; !ok || !f.Enabled {
|
|
return nil
|
|
}
|
|
}
|
|
out := []model.AgentRecord{}
|
|
for id, rec := range a.store.data.Agents {
|
|
if rec.MaintenanceMode != "online" || !agentOnline(rec, now, a.cfg.OfflineAfterSeconds) || rec.Snapshot.Health.Score < a.cfg.Broker.MinHealthScore {
|
|
continue
|
|
}
|
|
if !a.agentInFarmLocked(id, rec, farmID) {
|
|
continue
|
|
}
|
|
if !agentResourceReady(rec, resource) {
|
|
continue
|
|
}
|
|
out = append(out, rec)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) putLeaseLocked(key, user, agent, farm, res, reason string, now time.Time) model.UserLease {
|
|
l := model.UserLease{UserKey: user, AgentID: agent, FarmID: farm, ResourceID: res, CreatedAt: now, ExpiresAt: now.Add(time.Duration(a.cfg.Broker.LeaseSeconds) * time.Second), Reason: reason}
|
|
a.store.data.Leases[key] = l
|
|
return l
|
|
}
|
|
|
|
func brokerResponse(rec model.AgentRecord, farm string, res *model.Resource, reconnect bool, reason string, lease model.UserLease) model.BrokerResponse {
|
|
tokens := map[string]string{"SESSIONGUARD_HOST": rec.Snapshot.Server.Hostname, "SESSIONGUARD_AGENT_ID": rec.ID}
|
|
rid := ""
|
|
if res != nil {
|
|
rid = res.ID
|
|
tokens["SESSIONGUARD_RESOURCE_ID"] = res.ID
|
|
tokens["SESSIONGUARD_REMOTE_APP"] = res.RemoteApp
|
|
tokens["SESSIONGUARD_REMOTE_APP_DIR"] = res.RemoteAppDir
|
|
remoteArgs := res.RemoteAppArgs
|
|
if res.Kind == "remoteapp" && res.ManageRemoteApp {
|
|
switch res.RemoteAppCommandLine {
|
|
case 0:
|
|
remoteArgs = ""
|
|
case 2:
|
|
remoteArgs = res.RemoteAppRequiredArgs
|
|
}
|
|
}
|
|
tokens["SESSIONGUARD_REMOTE_APP_ARGS"] = remoteArgs
|
|
}
|
|
return model.BrokerResponse{AgentID: rec.ID, Hostname: rec.Snapshot.Server.Hostname, FarmID: farm, ResourceID: rid, Reconnect: reconnect, Reason: reason, HealthScore: rec.Snapshot.Health.Score, Tokens: tokens, LeaseExpires: lease.ExpiresAt}
|
|
}
|
|
|
|
func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
|
|
recs := a.store.all()
|
|
sort.Slice(recs, func(i, j int) bool { return strings.ToLower(recs[i].Name) < strings.ToLower(recs[j].Name) })
|
|
now := time.Now().UTC()
|
|
type row struct {
|
|
model.AgentRecord
|
|
Online bool `json:"online"`
|
|
Active int `json:"active_sessions"`
|
|
Disconnected int `json:"disconnected_sessions"`
|
|
Total int `json:"total_sessions"`
|
|
MemoryPercent float64 `json:"memory_percent"`
|
|
BrokerScore float64 `json:"broker_score"`
|
|
}
|
|
out := make([]row, 0, len(recs))
|
|
for _, rec := range recs {
|
|
active, disc := sessionCounts(rec.Snapshot.Sessions)
|
|
mem := 0.0
|
|
if rec.Snapshot.Server.MemoryTotal > 0 {
|
|
mem = float64(rec.Snapshot.Server.MemoryTotal-rec.Snapshot.Server.MemoryAvailable) * 100 / float64(rec.Snapshot.Server.MemoryTotal)
|
|
}
|
|
out = append(out, row{AgentRecord: rec, Online: agentOnline(rec, now, a.cfg.OfflineAfterSeconds), Active: active, Disconnected: disc, Total: active + disc, MemoryPercent: mem, BrokerScore: brokerScore(rec)})
|
|
}
|
|
a.store.mu.RLock()
|
|
farms := len(a.store.data.Farms)
|
|
resources := len(a.store.data.Resources)
|
|
alerts := 0
|
|
accessSessions := 0
|
|
for _, x := range a.store.data.Alerts {
|
|
if x.Active {
|
|
alerts++
|
|
}
|
|
}
|
|
for _, sess := range a.store.data.AuthSessions {
|
|
if sess.ExpiresAt.IsZero() || now.Before(sess.ExpiresAt) {
|
|
accessSessions++
|
|
}
|
|
}
|
|
a.store.mu.RUnlock()
|
|
httpx.JSON(w, 200, map[string]any{"agents": out, "server_time": now, "farms": farms, "resources": resources, "active_alerts": alerts, "access_sessions": accessSessions, "store": a.store.kind()})
|
|
}
|
|
func (a *App) agentDetail(w http.ResponseWriter, r *http.Request) {
|
|
rec, ok := a.store.get(r.PathValue("id"))
|
|
if !ok {
|
|
httpx.Error(w, 404, "agent not found")
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, rec)
|
|
}
|
|
|
|
func (a *App) policy(w http.ResponseWriter, r *http.Request) {
|
|
if !httpx.SameOrigin(r) {
|
|
httpx.Error(w, 403, "cross-origin request rejected")
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
var p model.Policy
|
|
if err := httpx.DecodeJSON(r, &p, 2<<20); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
config.NormalizePolicy(&p)
|
|
if err := config.ValidatePolicy(p); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
p.Revision = randomToken(12)
|
|
p.UpdatedAt = time.Now().UTC()
|
|
actor := requestActor(r)
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
rec, ok := a.store.data.Agents[id]
|
|
if !ok {
|
|
httpx.Error(w, 404, "agent not found")
|
|
return
|
|
}
|
|
rec.DesiredPolicy = &p
|
|
a.store.data.Agents[id] = rec
|
|
a.recordPolicyVersionLocked("agent:"+id, p, actor)
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "policy_update", Target: rec.Name, Result: "queued", Details: p.Revision})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, p)
|
|
}
|
|
func (a *App) policyAll(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
config.NormalizePolicy(&p)
|
|
if err := config.ValidatePolicy(p); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
p.Revision = randomToken(12)
|
|
p.UpdatedAt = time.Now().UTC()
|
|
actor := requestActor(r)
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
a.store.data.GlobalPolicy = &p
|
|
a.recordPolicyVersionLocked("global", p, actor)
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "global_policy_update", Target: "all agents", Result: "queued", Details: p.Revision})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, p)
|
|
}
|
|
func (a *App) farmPolicy(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
config.NormalizePolicy(&p)
|
|
if err := config.ValidatePolicy(p); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
p.Revision = randomToken(12)
|
|
p.UpdatedAt = time.Now().UTC()
|
|
id, actor := r.PathValue("id"), requestActor(r)
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
f, ok := a.store.data.Farms[id]
|
|
if !ok {
|
|
httpx.Error(w, 404, "farm not found")
|
|
return
|
|
}
|
|
f.Policy = &p
|
|
a.store.data.Farms[id] = f
|
|
a.recordPolicyVersionLocked("farm:"+id, p, actor)
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "farm_policy_update", Target: f.Name, Result: "queued", Details: p.Revision})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, p)
|
|
}
|
|
|
|
func (a *App) globalPolicyRollback(w http.ResponseWriter, r *http.Request) {
|
|
a.rollbackPolicyTarget(w, r, "global", "")
|
|
}
|
|
func (a *App) farmPolicyRollback(w http.ResponseWriter, r *http.Request) {
|
|
a.rollbackPolicyTarget(w, r, "farm:"+r.PathValue("id"), r.PathValue("id"))
|
|
}
|
|
func (a *App) rollbackPolicyTarget(w http.ResponseWriter, r *http.Request, target, farmID string) {
|
|
if !httpx.SameOrigin(r) {
|
|
httpx.Error(w, 403, "cross-origin request rejected")
|
|
return
|
|
}
|
|
rev, actor := r.PathValue("revision"), requestActor(r)
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
var found *model.Policy
|
|
for i := len(a.store.data.PolicyHistory) - 1; i >= 0; i-- {
|
|
v := a.store.data.PolicyHistory[i]
|
|
if v.Target == target && v.Revision == rev {
|
|
p := v.Policy
|
|
found = &p
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
httpx.Error(w, 404, "revision not found")
|
|
return
|
|
}
|
|
found.Revision = randomToken(12)
|
|
found.UpdatedAt = time.Now().UTC()
|
|
name := "global"
|
|
if target == "global" {
|
|
a.store.data.GlobalPolicy = found
|
|
} else {
|
|
f, ok := a.store.data.Farms[farmID]
|
|
if !ok {
|
|
httpx.Error(w, 404, "farm not found")
|
|
return
|
|
}
|
|
f.Policy = found
|
|
a.store.data.Farms[farmID] = f
|
|
name = f.Name
|
|
}
|
|
a.recordPolicyVersionLocked(target, *found, actor)
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "policy_rollback", Target: name, Result: "queued", Details: rev + " -> " + found.Revision})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, found)
|
|
}
|
|
|
|
func (a *App) recordPolicyVersionLocked(target string, p model.Policy, actor string) {
|
|
a.store.data.PolicyHistory = append(a.store.data.PolicyHistory, model.PolicyVersion{ID: randomToken(10), Target: target, Revision: p.Revision, CreatedAt: time.Now().UTC(), Actor: actor, Policy: p})
|
|
if len(a.store.data.PolicyHistory) > 500 {
|
|
a.store.data.PolicyHistory = append([]model.PolicyVersion(nil), a.store.data.PolicyHistory[len(a.store.data.PolicyHistory)-500:]...)
|
|
}
|
|
}
|
|
func (a *App) policyRollback(w http.ResponseWriter, r *http.Request) {
|
|
id, rev := r.PathValue("id"), r.PathValue("revision")
|
|
actor := requestActor(r)
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
rec, ok := a.store.data.Agents[id]
|
|
if !ok {
|
|
httpx.Error(w, 404, "agent not found")
|
|
return
|
|
}
|
|
var found *model.Policy
|
|
for i := len(a.store.data.PolicyHistory) - 1; i >= 0; i-- {
|
|
v := a.store.data.PolicyHistory[i]
|
|
if v.Target == "agent:"+id && v.Revision == rev {
|
|
p := v.Policy
|
|
found = &p
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
httpx.Error(w, 404, "revision not found")
|
|
return
|
|
}
|
|
found.Revision = randomToken(12)
|
|
found.UpdatedAt = time.Now().UTC()
|
|
rec.DesiredPolicy = found
|
|
a.store.data.Agents[id] = rec
|
|
a.recordPolicyVersionLocked("agent:"+id, *found, actor)
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: actor, Action: "policy_rollback", Target: rec.Name, Result: "queued", Details: rev + " -> " + found.Revision})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, found)
|
|
}
|
|
func (a *App) policyHistory(w http.ResponseWriter, r *http.Request) {
|
|
a.store.mu.RLock()
|
|
defer a.store.mu.RUnlock()
|
|
httpx.JSON(w, 200, map[string]any{"history": append([]model.PolicyVersion(nil), a.store.data.PolicyHistory...)})
|
|
}
|
|
|
|
func (a *App) agentControl(w http.ResponseWriter, r *http.Request) {
|
|
if !httpx.SameOrigin(r) {
|
|
httpx.Error(w, 403, "cross-origin request rejected")
|
|
return
|
|
}
|
|
var req struct {
|
|
Mode string `json:"mode"`
|
|
RestartWhenDrained bool `json:"restart_when_drained"`
|
|
Tags map[string]string `json:"tags"`
|
|
FarmIDs []string `json:"farm_ids"`
|
|
}
|
|
if err := httpx.DecodeJSON(r, &req, 128<<10); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
mode := strings.ToLower(strings.TrimSpace(req.Mode))
|
|
if mode != "" && mode != "online" && mode != "drain" && mode != "maintenance" {
|
|
httpx.Error(w, 400, "mode must be online, drain or maintenance")
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
rec, ok := a.store.data.Agents[id]
|
|
if !ok {
|
|
httpx.Error(w, 404, "agent not found")
|
|
return
|
|
}
|
|
if mode != "" {
|
|
rec.MaintenanceMode = mode
|
|
}
|
|
rec.RestartWhenDrained = req.RestartWhenDrained
|
|
if req.Tags != nil {
|
|
rec.Tags = req.Tags
|
|
}
|
|
if req.FarmIDs != nil {
|
|
rec.FarmIDs = req.FarmIDs
|
|
}
|
|
a.store.data.Agents[id] = rec
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "agent_control", Target: rec.Name, Result: "success", Details: fmt.Sprintf("mode=%s restart_when_drained=%v", rec.MaintenanceMode, rec.RestartWhenDrained)})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
rec.TokenHash = ""
|
|
httpx.JSON(w, 200, rec)
|
|
}
|
|
|
|
func (a *App) sessionAction(w http.ResponseWriter, r *http.Request) {
|
|
if !httpx.SameOrigin(r) {
|
|
httpx.Error(w, 403, "cross-origin request rejected")
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
session64, err := strconv.ParseUint(r.PathValue("session"), 10, 32)
|
|
if err != nil {
|
|
httpx.Error(w, 400, "invalid session id")
|
|
return
|
|
}
|
|
var req model.SessionActionRequest
|
|
if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
action := strings.ToLower(strings.TrimSpace(req.Action))
|
|
if action != "logoff" && action != "disconnect" && action != "message" {
|
|
httpx.Error(w, 400, "invalid action")
|
|
return
|
|
}
|
|
if action == "logoff" && !a.hasPermission(r, "session_logoff") {
|
|
httpx.Error(w, 403, "role may not log off sessions")
|
|
return
|
|
}
|
|
if action == "message" && strings.TrimSpace(req.Message) == "" {
|
|
httpx.Error(w, 400, "message is required")
|
|
return
|
|
}
|
|
cmd := model.SessionCommand{ID: randomToken(12), Action: action, SessionID: uint32(session64), Title: req.Title, Message: req.Message, RequestedBy: requestActor(r), CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(2 * time.Minute)}
|
|
a.queueCommand(w, id, cmd)
|
|
}
|
|
func (a *App) sessionBulkAction(w http.ResponseWriter, r *http.Request) {
|
|
if !httpx.SameOrigin(r) {
|
|
httpx.Error(w, 403, "cross-origin request rejected")
|
|
return
|
|
}
|
|
var req struct {
|
|
Action string `json:"action"`
|
|
Scope string `json:"scope"`
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := httpx.DecodeJSON(r, &req, 64<<10); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
action := strings.ToLower(strings.TrimSpace(req.Action))
|
|
scope := strings.ToLower(strings.TrimSpace(req.Scope))
|
|
if scope == "" {
|
|
scope = "all"
|
|
}
|
|
if action != "logoff" && action != "disconnect" && action != "message" {
|
|
httpx.Error(w, 400, "invalid action")
|
|
return
|
|
}
|
|
if scope != "all" && scope != "disconnected" && scope != "active" {
|
|
httpx.Error(w, 400, "scope must be all, active or disconnected")
|
|
return
|
|
}
|
|
if action == "logoff" && !a.hasPermission(r, "session_logoff") {
|
|
httpx.Error(w, 403, "role may not log off sessions")
|
|
return
|
|
}
|
|
if action == "message" && strings.TrimSpace(req.Message) == "" {
|
|
httpx.Error(w, 400, "message is required")
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
now := time.Now().UTC()
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
rec, ok := a.store.data.Agents[id]
|
|
if !ok {
|
|
httpx.Error(w, 404, "agent not found")
|
|
return
|
|
}
|
|
policy := rec.Snapshot.Policy
|
|
if p := a.effectivePolicyLocked(rec); p != nil {
|
|
policy = *p
|
|
}
|
|
if !policy.Sessions.ControlEnabled {
|
|
httpx.Error(w, 409, "session control is disabled by policy")
|
|
return
|
|
}
|
|
added := 0
|
|
for _, sess := range rec.Snapshot.Sessions {
|
|
if sess.User == "" {
|
|
continue
|
|
}
|
|
state := strings.ToLower(sess.State)
|
|
if scope == "disconnected" && state != "disconnected" {
|
|
continue
|
|
}
|
|
if scope == "active" && state != "active" && state != "connected" {
|
|
continue
|
|
}
|
|
if len(rec.PendingCommands) >= 100 {
|
|
break
|
|
}
|
|
rec.PendingCommands = append(rec.PendingCommands, model.SessionCommand{ID: randomToken(12), Action: action, SessionID: sess.ID, Title: req.Title, Message: req.Message, RequestedBy: requestActor(r), CreatedAt: now, ExpiresAt: now.Add(2 * time.Minute)})
|
|
added++
|
|
}
|
|
a.store.data.Agents[id] = rec
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: now, Actor: requestActor(r), Action: "bulk:" + action, Target: rec.Name, Result: "queued", Details: fmt.Sprintf("scope=%s sessions=%d", scope, added)})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 202, map[string]any{"queued": added})
|
|
}
|
|
|
|
func (a *App) processKill(w http.ResponseWriter, r *http.Request) {
|
|
if !httpx.SameOrigin(r) {
|
|
httpx.Error(w, 403, "cross-origin request rejected")
|
|
return
|
|
}
|
|
pid64, err := strconv.ParseUint(r.PathValue("pid"), 10, 32)
|
|
if err != nil {
|
|
httpx.Error(w, 400, "invalid pid")
|
|
return
|
|
}
|
|
agentID := r.PathValue("id")
|
|
rec, ok := a.store.get(agentID)
|
|
if !ok {
|
|
httpx.Error(w, 404, "agent not found")
|
|
return
|
|
}
|
|
pid := uint32(pid64)
|
|
var sessionID uint32
|
|
for _, process := range rec.Snapshot.Processes {
|
|
if process.PID == pid {
|
|
sessionID = process.SessionID
|
|
break
|
|
}
|
|
}
|
|
if sessionID == 0 {
|
|
httpx.Error(w, 404, "process is no longer present in a user session")
|
|
return
|
|
}
|
|
cmd := model.SessionCommand{ID: randomToken(12), Action: "kill_process", SessionID: sessionID, PID: pid, RequestedBy: requestActor(r), CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(2 * time.Minute)}
|
|
a.queueCommand(w, agentID, cmd)
|
|
}
|
|
func (a *App) queueCommand(w http.ResponseWriter, id string, cmd model.SessionCommand) {
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
rec, ok := a.store.data.Agents[id]
|
|
if !ok {
|
|
httpx.Error(w, 404, "agent not found")
|
|
return
|
|
}
|
|
policy := rec.Snapshot.Policy
|
|
if p := a.effectivePolicyLocked(rec); p != nil {
|
|
policy = *p
|
|
}
|
|
if !policy.Sessions.ControlEnabled {
|
|
httpx.Error(w, 409, "session control is disabled by policy")
|
|
return
|
|
}
|
|
if len(rec.PendingCommands) >= 100 {
|
|
httpx.Error(w, 429, "too many pending commands")
|
|
return
|
|
}
|
|
rec.PendingCommands = append(rec.PendingCommands, cmd)
|
|
a.store.data.Agents[id] = rec
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: cmd.RequestedBy, Action: "command:" + cmd.Action, Target: commandTarget(rec, cmd), Result: "queued"})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 202, cmd)
|
|
}
|
|
|
|
func (a *App) farms(w http.ResponseWriter, r *http.Request) {
|
|
a.store.mu.RLock()
|
|
defer a.store.mu.RUnlock()
|
|
out := make([]model.Farm, 0, len(a.store.data.Farms))
|
|
for _, f := range a.store.data.Farms {
|
|
out = append(out, f)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
|
|
httpx.JSON(w, 200, map[string]any{"farms": out})
|
|
}
|
|
func (a *App) farmCreate(w http.ResponseWriter, r *http.Request) {
|
|
var f model.Farm
|
|
if err := httpx.DecodeJSON(r, &f, 256<<10); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
if f.ID == "" {
|
|
f.ID = randomToken(8)
|
|
}
|
|
a.saveFarm(w, r, f, false)
|
|
}
|
|
func (a *App) farmUpdate(w http.ResponseWriter, r *http.Request) {
|
|
var f model.Farm
|
|
if err := httpx.DecodeJSON(r, &f, 256<<10); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
f.ID = r.PathValue("id")
|
|
a.saveFarm(w, r, f, true)
|
|
}
|
|
func (a *App) saveFarm(w http.ResponseWriter, r *http.Request, f model.Farm, mustExist bool) {
|
|
if strings.TrimSpace(f.Name) == "" {
|
|
httpx.Error(w, 400, "farm name is required")
|
|
return
|
|
}
|
|
if f.RequiredTags == nil {
|
|
f.RequiredTags = map[string]string{}
|
|
}
|
|
if f.Policy != nil {
|
|
config.NormalizePolicy(f.Policy)
|
|
if err := config.ValidatePolicy(*f.Policy); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
if f.Policy.Revision == "" {
|
|
f.Policy.Revision = randomToken(12)
|
|
f.Policy.UpdatedAt = time.Now().UTC()
|
|
}
|
|
}
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
_, exists := a.store.data.Farms[f.ID]
|
|
if mustExist && !exists {
|
|
httpx.Error(w, 404, "farm not found")
|
|
return
|
|
}
|
|
a.store.data.Farms[f.ID] = f
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "farm_save", Target: f.Name, Result: "success"})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, f)
|
|
}
|
|
func (a *App) farmDelete(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
f, ok := a.store.data.Farms[id]
|
|
if !ok {
|
|
httpx.Error(w, 404, "farm not found")
|
|
return
|
|
}
|
|
delete(a.store.data.Farms, id)
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "farm_delete", Target: f.Name, Result: "success"})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
func (a *App) resources(w http.ResponseWriter, r *http.Request) {
|
|
a.store.mu.RLock()
|
|
defer a.store.mu.RUnlock()
|
|
out := make([]model.Resource, 0, len(a.store.data.Resources))
|
|
for _, x := range a.store.data.Resources {
|
|
out = append(out, x)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
|
|
httpx.JSON(w, 200, map[string]any{"resources": out})
|
|
}
|
|
func (a *App) resourceCreate(w http.ResponseWriter, r *http.Request) {
|
|
var x model.Resource
|
|
if err := httpx.DecodeJSON(r, &x, 128<<10); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
if x.ID == "" {
|
|
x.ID = randomToken(8)
|
|
}
|
|
a.saveResource(w, r, x, false)
|
|
}
|
|
func (a *App) resourceUpdate(w http.ResponseWriter, r *http.Request) {
|
|
var x model.Resource
|
|
if err := httpx.DecodeJSON(r, &x, 128<<10); err != nil {
|
|
httpx.Error(w, 400, err.Error())
|
|
return
|
|
}
|
|
x.ID = r.PathValue("id")
|
|
a.saveResource(w, r, x, true)
|
|
}
|
|
func (a *App) saveResource(w http.ResponseWriter, r *http.Request, x model.Resource, mustExist bool) {
|
|
if strings.TrimSpace(x.Name) == "" || strings.TrimSpace(x.FarmID) == "" {
|
|
httpx.Error(w, 400, "resource name and farm_id are required")
|
|
return
|
|
}
|
|
if x.Kind != "desktop" && x.Kind != "remoteapp" {
|
|
httpx.Error(w, 400, "kind must be desktop or remoteapp")
|
|
return
|
|
}
|
|
if x.Kind == "remoteapp" && strings.TrimSpace(x.RemoteApp) == "" {
|
|
httpx.Error(w, 400, "remote_app is required for remoteapp resources")
|
|
return
|
|
}
|
|
if x.Kind == "desktop" {
|
|
x.RemoteApp = ""
|
|
x.RemoteAppDir = ""
|
|
x.RemoteAppArgs = ""
|
|
x.ManageRemoteApp = false
|
|
x.RemoteAppPath = ""
|
|
x.RemoteAppIconPath = ""
|
|
x.RemoteAppIconIndex = 0
|
|
x.RemoteAppCommandLine = 0
|
|
x.RemoteAppRequiredArgs = ""
|
|
x.RemoteAppShowInPortal = false
|
|
}
|
|
if x.Kind == "remoteapp" {
|
|
alias := remoteAppAlias(x.RemoteApp)
|
|
if !validRemoteAppAlias(alias) {
|
|
httpx.Error(w, 400, "remote_app alias must contain only letters, numbers, dot, dash or underscore")
|
|
return
|
|
}
|
|
x.RemoteApp = "||" + alias
|
|
if x.ManageRemoteApp && strings.TrimSpace(x.RemoteAppPath) == "" {
|
|
httpx.Error(w, 400, "remote_app_path is required when agent RemoteApp management is enabled")
|
|
return
|
|
}
|
|
if x.RemoteAppCommandLine > 2 {
|
|
httpx.Error(w, 400, "remote_app_command_line_setting must be 0, 1 or 2")
|
|
return
|
|
}
|
|
}
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
if _, ok := a.store.data.Farms[x.FarmID]; !ok {
|
|
httpx.Error(w, 400, "farm does not exist")
|
|
return
|
|
}
|
|
_, exists := a.store.data.Resources[x.ID]
|
|
if mustExist && !exists {
|
|
httpx.Error(w, 404, "resource not found")
|
|
return
|
|
}
|
|
if x.Enabled {
|
|
for id, existing := range a.store.data.Resources {
|
|
if id == x.ID || !existing.Enabled {
|
|
continue
|
|
}
|
|
if x.GuacamoleConnectionID != "" && existing.GuacamoleConnectionID == x.GuacamoleConnectionID {
|
|
httpx.Error(w, 409, "guacamole_connection_id is already mapped by another enabled resource")
|
|
return
|
|
}
|
|
if x.GuacamoleConnectionName != "" && strings.EqualFold(existing.GuacamoleConnectionName, x.GuacamoleConnectionName) {
|
|
httpx.Error(w, 409, "guacamole_connection_name is already mapped by another enabled resource")
|
|
return
|
|
}
|
|
if x.Kind == "remoteapp" && x.ManageRemoteApp && existing.Kind == "remoteapp" && existing.ManageRemoteApp && strings.EqualFold(remoteAppAlias(existing.RemoteApp), remoteAppAlias(x.RemoteApp)) {
|
|
httpx.Error(w, 409, "managed remote_app alias is already used by another enabled resource")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
a.store.data.Resources[x.ID] = x
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "resource_save", Target: x.Name, Result: "success"})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
httpx.JSON(w, 200, x)
|
|
}
|
|
func (a *App) resourceDelete(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
a.store.mu.Lock()
|
|
defer a.store.mu.Unlock()
|
|
x, ok := a.store.data.Resources[id]
|
|
if !ok {
|
|
httpx.Error(w, 404, "resource not found")
|
|
return
|
|
}
|
|
delete(a.store.data.Resources, id)
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "resource_delete", Target: x.Name, Result: "success"})
|
|
if err := a.store.saveLocked(); err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (a *App) history(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
items := a.store.history(limit)
|
|
user := normalizeUser(r.URL.Query().Get("user"))
|
|
agent := strings.TrimSpace(r.URL.Query().Get("agent"))
|
|
if user != "" || agent != "" {
|
|
filtered := make([]model.SessionHistoryEvent, 0, len(items))
|
|
for _, x := range items {
|
|
if user != "" && normalizeUser(x.User) != user {
|
|
continue
|
|
}
|
|
if agent != "" && x.AgentID != agent {
|
|
continue
|
|
}
|
|
filtered = append(filtered, x)
|
|
}
|
|
items = filtered
|
|
}
|
|
httpx.JSON(w, 200, map[string]any{"history": items})
|
|
}
|
|
func (a *App) audit(w http.ResponseWriter, r *http.Request) {
|
|
httpx.JSON(w, 200, map[string]any{"audit": a.store.audit(500)})
|
|
}
|
|
func (a *App) alerts(w http.ResponseWriter, r *http.Request) {
|
|
a.store.mu.RLock()
|
|
defer a.store.mu.RUnlock()
|
|
out := make([]model.Alert, 0, len(a.store.data.Alerts))
|
|
for _, x := range a.store.data.Alerts {
|
|
out = append(out, x)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Active != out[j].Active {
|
|
return out[i].Active
|
|
}
|
|
return out[i].LastSeenAt.After(out[j].LastSeenAt)
|
|
})
|
|
httpx.JSON(w, 200, map[string]any{"alerts": out})
|
|
}
|
|
|
|
func (a *App) accessSessions(w http.ResponseWriter, r *http.Request) {
|
|
type publicSession struct {
|
|
ID string `json:"id"`
|
|
Subject string `json:"subject"`
|
|
SID string `json:"sid,omitempty"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Groups []string `json:"groups,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
now := time.Now().UTC()
|
|
a.store.mu.RLock()
|
|
out := make([]publicSession, 0, len(a.store.data.AuthSessions))
|
|
for _, sess := range a.store.data.AuthSessions {
|
|
if !sess.ExpiresAt.IsZero() && !now.Before(sess.ExpiresAt) {
|
|
continue
|
|
}
|
|
out = append(out, publicSession{ID: sess.ID, Subject: sess.Subject, SID: sess.SID, Username: sess.Username, Email: sess.Email, Name: sess.Name, Groups: append([]string(nil), sess.Groups...), CreatedAt: sess.CreatedAt, ExpiresAt: sess.ExpiresAt})
|
|
}
|
|
a.store.mu.RUnlock()
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
|
httpx.JSON(w, 200, map[string]any{"sessions": out})
|
|
}
|
|
|
|
func (a *App) accessSessionRevoke(w http.ResponseWriter, r *http.Request) {
|
|
if !httpx.SameOrigin(r) {
|
|
httpx.Error(w, 403, "cross-origin request rejected")
|
|
return
|
|
}
|
|
id := strings.TrimSpace(r.PathValue("id"))
|
|
if id == "" {
|
|
httpx.Error(w, 400, "session id is required")
|
|
return
|
|
}
|
|
a.store.mu.Lock()
|
|
var username string
|
|
var hash string
|
|
for h, sess := range a.store.data.AuthSessions {
|
|
if sess.ID == id {
|
|
hash, username = h, sess.Username
|
|
break
|
|
}
|
|
}
|
|
if hash == "" {
|
|
a.store.mu.Unlock()
|
|
httpx.Error(w, 404, "access session not found")
|
|
return
|
|
}
|
|
delete(a.store.data.AuthSessions, hash)
|
|
a.store.appendAuditLocked(model.AuditEntry{Time: time.Now().UTC(), Actor: requestActor(r), Action: "access_session_revoke", Target: username, Result: "success", Details: id})
|
|
err := a.store.saveLocked()
|
|
a.store.mu.Unlock()
|
|
if err != nil {
|
|
httpx.Error(w, 500, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (a *App) leases(w http.ResponseWriter, r *http.Request) {
|
|
now := time.Now().UTC()
|
|
a.store.mu.RLock()
|
|
out := make([]model.UserLease, 0, len(a.store.data.Leases))
|
|
for _, l := range a.store.data.Leases {
|
|
if now.Before(l.ExpiresAt) {
|
|
out = append(out, l)
|
|
}
|
|
}
|
|
a.store.mu.RUnlock()
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
|
|
httpx.JSON(w, 200, map[string]any{"leases": out})
|
|
}
|
|
|
|
func (a *App) monitor(ctx context.Context) {
|
|
t := time.NewTicker(15 * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case now := <-t.C:
|
|
a.store.mu.Lock()
|
|
leasesChanged := false
|
|
for key, lease := range a.store.data.Leases {
|
|
if now.After(lease.ExpiresAt) {
|
|
delete(a.store.data.Leases, key)
|
|
leasesChanged = true
|
|
}
|
|
}
|
|
if !a.cfg.Alerts.Enabled {
|
|
if leasesChanged {
|
|
if err := a.store.saveLocked(); err != nil {
|
|
log.Printf("master monitor persistence: %v", err)
|
|
}
|
|
}
|
|
a.store.mu.Unlock()
|
|
continue
|
|
}
|
|
changed := []model.Alert{}
|
|
for id, rec := range a.store.data.Agents {
|
|
if now.Sub(rec.LastSeen) > time.Duration(a.cfg.Alerts.OfflineSeconds)*time.Second {
|
|
changed = append(changed, a.setAlertLocked("offline:"+id, rec, "critical", "agent_offline", fmt.Sprintf("Agent seit %s nicht erreichbar", now.Sub(rec.LastSeen).Round(time.Second)), true, now)...)
|
|
} else {
|
|
changed = append(changed, a.setAlertLocked("offline:"+id, rec, "critical", "agent_offline", "", false, now)...)
|
|
}
|
|
}
|
|
if err := a.store.saveLocked(); err != nil {
|
|
log.Printf("master monitor persistence: %v", err)
|
|
}
|
|
a.store.mu.Unlock()
|
|
for _, al := range changed {
|
|
a.notifyAlert(al)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
func (a *App) evaluateAgentAlertsLocked(rec model.AgentRecord, now time.Time) []model.Alert {
|
|
if !a.cfg.Alerts.Enabled {
|
|
return nil
|
|
}
|
|
changed := []model.Alert{}
|
|
s := rec.Snapshot.Server
|
|
mem := 0.0
|
|
if s.MemoryTotal > 0 {
|
|
mem = float64(s.MemoryTotal-s.MemoryAvailable) * 100 / float64(s.MemoryTotal)
|
|
}
|
|
checks := []struct {
|
|
key, sev, typ, msg string
|
|
active bool
|
|
}{{"cpu:" + rec.ID, "warning", "cpu_high", fmt.Sprintf("CPU %.1f%%", s.CPUPercent), s.CPUPercent >= float64(a.cfg.Alerts.CPUPercent)}, {"memory:" + rec.ID, "warning", "memory_high", fmt.Sprintf("RAM %.1f%%", mem), mem >= float64(a.cfg.Alerts.MemoryPercent)}, {"disk:" + rec.ID, "critical", "disk_low", fmt.Sprintf("Systemdisk %d GiB frei", s.DiskFree>>30), s.DiskTotal > 0 && s.DiskFree < uint64(a.cfg.Alerts.DiskFreeGB)<<30}, {"health:" + rec.ID, "critical", "health_low", fmt.Sprintf("Health Score %d", rec.Snapshot.Health.Score), rec.Snapshot.Health.Score < a.cfg.Alerts.HealthScore}}
|
|
_, disc := sessionCounts(rec.Snapshot.Sessions)
|
|
checks = append(checks, struct {
|
|
key, sev, typ, msg string
|
|
active bool
|
|
}{"disc:" + rec.ID, "warning", "disconnected_sessions", fmt.Sprintf("%d getrennte Sitzungen", disc), disc >= a.cfg.Alerts.DisconnectedSessions})
|
|
maxLogonMS := int64(0)
|
|
for _, telemetry := range rec.Snapshot.Telemetry {
|
|
if telemetry.ObservedLogonMS > maxLogonMS {
|
|
maxLogonMS = telemetry.ObservedLogonMS
|
|
}
|
|
}
|
|
checks = append(checks, struct {
|
|
key, sev, typ, msg string
|
|
active bool
|
|
}{"logon:" + rec.ID, "warning", "logon_slow", fmt.Sprintf("Letzte gemessene Logon-Pipeline %.1f s", float64(maxLogonMS)/1000), maxLogonMS >= int64(a.cfg.Alerts.LogonDurationSeconds)*1000})
|
|
fail := 0
|
|
for _, st := range rec.Snapshot.ProfileStatus {
|
|
if st.LastBackupError != "" || st.LastRestoreError != "" {
|
|
fail++
|
|
}
|
|
}
|
|
checks = append(checks, struct {
|
|
key, sev, typ, msg string
|
|
active bool
|
|
}{"profiles:" + rec.ID, "critical", "profile_failures", fmt.Sprintf("%d Profile mit Fehlerstatus", fail), fail >= a.cfg.Alerts.ProfileFailures})
|
|
for _, c := range checks {
|
|
changed = append(changed, a.setAlertLocked(c.key, rec, c.sev, c.typ, c.msg, c.active, now)...)
|
|
}
|
|
return changed
|
|
}
|
|
func (a *App) setAlertLocked(key string, rec model.AgentRecord, severity, typ, msg string, active bool, now time.Time) []model.Alert {
|
|
al, exists := a.store.data.Alerts[key]
|
|
if !exists {
|
|
al = model.Alert{ID: randomToken(8), Key: key, AgentID: rec.ID, Hostname: rec.Name, Severity: severity, Type: typ, FirstSeenAt: now}
|
|
}
|
|
if active {
|
|
was := al.Active
|
|
al.Active = true
|
|
al.LastSeenAt = now
|
|
al.Message = msg
|
|
al.Severity = severity
|
|
if !was {
|
|
al.FirstSeenAt = now
|
|
}
|
|
a.store.data.Alerts[key] = al
|
|
if !was || now.Sub(al.LastNotifiedAt) >= time.Duration(a.cfg.Alerts.NotificationMinInterval)*time.Second {
|
|
al.LastNotifiedAt = now
|
|
a.store.data.Alerts[key] = al
|
|
return []model.Alert{al}
|
|
}
|
|
return nil
|
|
}
|
|
if exists && al.Active {
|
|
al.Active = false
|
|
al.ResolvedAt = now
|
|
al.LastSeenAt = now
|
|
al.LastNotifiedAt = now
|
|
a.store.data.Alerts[key] = al
|
|
return []model.Alert{al}
|
|
}
|
|
return nil
|
|
}
|
|
func (a *App) notifyAlert(al model.Alert) {
|
|
if !a.cfg.Alerts.Enabled || strings.TrimSpace(a.cfg.Alerts.WebhookURL) == "" {
|
|
return
|
|
}
|
|
body, _ := json.Marshal(al)
|
|
req, err := http.NewRequest(http.MethodPost, a.cfg.Alerts.WebhookURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := a.http.Do(req)
|
|
if err != nil {
|
|
log.Printf("alert webhook: %v", err)
|
|
return
|
|
}
|
|
_ = resp.Body.Close()
|
|
}
|
|
|
|
func (a *App) metrics(w http.ResponseWriter, r *http.Request) {
|
|
recs := a.store.all()
|
|
now := time.Now().UTC()
|
|
online, active, disconnected, profileJobs, cleanupJobs := 0, 0, 0, 0, 0
|
|
healthTotal := 0
|
|
for _, rec := range recs {
|
|
if agentOnline(rec, now, a.cfg.OfflineAfterSeconds) {
|
|
online++
|
|
}
|
|
aa, dd := sessionCounts(rec.Snapshot.Sessions)
|
|
active += aa
|
|
disconnected += dd
|
|
profileJobs += len(rec.Snapshot.ProfileJobs)
|
|
cleanupJobs += len(rec.Snapshot.PendingCleanup)
|
|
healthTotal += rec.Snapshot.Health.Score
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
|
fmt.Fprintf(w, "sessionguard_master_up 1\nsessionguard_agents_total %d\nsessionguard_agents_online %d\nsessionguard_sessions_active %d\nsessionguard_sessions_disconnected %d\nsessionguard_profile_jobs %d\nsessionguard_cleanup_jobs %d\nsessionguard_health_score_sum %d\n", len(recs), online, active, disconnected, profileJobs, cleanupJobs, healthTotal)
|
|
}
|
|
|
|
func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
|
u, _ := auth.UserFrom(r)
|
|
httpx.JSON(w, 200, map[string]any{"user": u, "roles": a.roles(r), "permissions": a.permissions(r)})
|
|
}
|
|
|
|
var rolePermissions = map[string][]string{"viewer": {"view"}, "helpdesk": {"view", "session"}, "operator": {"view", "session", "session_logoff", "process", "maintenance"}, "profile_admin": {"view", "session", "profile"}, "policy_admin": {"view", "policy"}, "auditor": {"view", "audit"}, "admin": {"view", "session", "session_logoff", "process", "maintenance", "profile", "policy", "audit", "manage"}}
|
|
|
|
func (a *App) roles(r *http.Request) []string {
|
|
u, ok := auth.UserFrom(r)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if len(a.cfg.RBAC.Groups) == 0 {
|
|
return []string{"admin"}
|
|
}
|
|
set := map[string]bool{}
|
|
for _, g := range u.Groups {
|
|
for configured, roles := range a.cfg.RBAC.Groups {
|
|
if strings.EqualFold(g, configured) {
|
|
for _, role := range roles {
|
|
set[strings.ToLower(role)] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(set) == 0 && a.cfg.RBAC.DefaultRole != "" {
|
|
set[strings.ToLower(a.cfg.RBAC.DefaultRole)] = true
|
|
}
|
|
out := make([]string, 0, len(set))
|
|
for role := range set {
|
|
out = append(out, role)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
func (a *App) permissions(r *http.Request) []string {
|
|
set := map[string]bool{}
|
|
for _, role := range a.roles(r) {
|
|
for _, p := range rolePermissions[role] {
|
|
set[p] = true
|
|
}
|
|
}
|
|
out := make([]string, 0, len(set))
|
|
for p := range set {
|
|
out = append(out, p)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
func (a *App) hasPermission(r *http.Request, p string) bool {
|
|
for _, x := range a.permissions(r) {
|
|
if x == p {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func (a *App) require(permission string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !a.hasPermission(r, permission) {
|
|
httpx.Error(w, 403, "insufficient role permission: "+permission)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
func requestActor(r *http.Request) string {
|
|
u, ok := auth.UserFrom(r)
|
|
if !ok {
|
|
return "unknown"
|
|
}
|
|
if strings.TrimSpace(u.Email) != "" {
|
|
return u.Email
|
|
}
|
|
if strings.TrimSpace(u.Name) != "" {
|
|
return u.Name
|
|
}
|
|
return u.Sub
|
|
}
|
|
|
|
func (a *App) masterPage(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
|
_, _ = fmt.Fprint(w, masterHTML)
|
|
}
|
|
func randomToken(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
func hashToken(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
|
|
func constantEqual(x, y string) bool {
|
|
if len(x) != len(y) {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(x), []byte(y)) == 1
|
|
}
|
|
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 commandTarget(rec model.AgentRecord, cmd model.SessionCommand) string {
|
|
if cmd.PID != 0 {
|
|
return fmt.Sprintf("%s/process/%d", rec.Name, cmd.PID)
|
|
}
|
|
if cmd.SessionID != 0 {
|
|
return fmt.Sprintf("%s/session/%d", rec.Name, cmd.SessionID)
|
|
}
|
|
return rec.Name
|
|
}
|
|
func userSessionCount(ss []model.Session) int {
|
|
n := 0
|
|
for _, s := range ss {
|
|
if s.User != "" {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
func hasPendingAction(cmds []model.SessionCommand, action string) bool {
|
|
for _, c := range cmds {
|
|
if c.Action == action {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func agentOnline(rec model.AgentRecord, now time.Time, seconds int) bool {
|
|
return !rec.LastSeen.IsZero() && now.Sub(rec.LastSeen) < time.Duration(seconds)*time.Second
|
|
}
|
|
func sessionCounts(ss []model.Session) (int, int) {
|
|
a, d := 0, 0
|
|
for _, s := range ss {
|
|
if s.User == "" {
|
|
continue
|
|
}
|
|
if s.State == "Active" {
|
|
a++
|
|
}
|
|
if s.State == "Disconnected" {
|
|
d++
|
|
}
|
|
}
|
|
return a, d
|
|
}
|
|
func brokerScore(rec model.AgentRecord) float64 {
|
|
active, disc := sessionCounts(rec.Snapshot.Sessions)
|
|
mem := 0.0
|
|
if rec.Snapshot.Server.MemoryTotal > 0 {
|
|
mem = float64(rec.Snapshot.Server.MemoryTotal-rec.Snapshot.Server.MemoryAvailable) * 100 / float64(rec.Snapshot.Server.MemoryTotal)
|
|
}
|
|
return float64(rec.Snapshot.Health.Score)*10 - float64(active)*20 - float64(disc)*5 - rec.Snapshot.Server.CPUPercent*2 - mem
|
|
}
|
|
func normalizeUser(u string) string {
|
|
u = strings.ToLower(strings.TrimSpace(u))
|
|
u = strings.ReplaceAll(u, "/", `\`)
|
|
return u
|
|
}
|
|
func sessionMatchesUser(s model.Session, key string) bool {
|
|
if s.User == "" {
|
|
return false
|
|
}
|
|
key = normalizeUser(key)
|
|
short := normalizeUser(s.User)
|
|
qualified := normalizeUser(displaySessionUser(s))
|
|
|
|
// A caller that supplied an explicit DOMAIN\user identity is expressing a
|
|
// domain boundary. Never drop that qualifier merely because the short user
|
|
// name happens to match another domain.
|
|
if strings.Contains(key, `\`) {
|
|
return qualified == key
|
|
}
|
|
if key == short || key == qualified {
|
|
return true
|
|
}
|
|
if s.Domain != "" && normalizeUser(s.User+"@"+s.Domain) == key {
|
|
return true
|
|
}
|
|
// PocketID preferred_username is often a UPN while WTS exposes a NetBIOS
|
|
// domain. In that case the UPN suffix cannot be reliably derived from WTS;
|
|
// permit local-part matching for UPNs, but retain strict matching above for
|
|
// explicit DOMAIN\user values.
|
|
if i := strings.Index(key, "@"); i > 0 && key[:i] == short {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
func displaySessionUser(s model.Session) string {
|
|
if s.Domain != "" {
|
|
return s.Domain + `\` + s.User
|
|
}
|
|
return s.User
|
|
}
|
|
func contains(xs []string, v string) bool {
|
|
for _, x := range xs {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func tagsMatch(have, need map[string]string) bool {
|
|
for k, v := range need {
|
|
if !strings.EqualFold(have[k], v) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
func remoteAppAlias(remoteApp string) string {
|
|
v := strings.TrimSpace(remoteApp)
|
|
v = strings.TrimPrefix(v, "||")
|
|
return strings.TrimSpace(v)
|
|
}
|
|
|
|
func validRemoteAppAlias(alias string) bool {
|
|
if alias == "" || len(alias) > 128 {
|
|
return false
|
|
}
|
|
for _, r := range alias {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (a *App) desiredRemoteAppsLocked(agentID string, rec model.AgentRecord) []model.RemoteAppSpec {
|
|
out := make([]model.RemoteAppSpec, 0)
|
|
for _, res := range a.store.data.Resources {
|
|
if !res.Enabled || res.Kind != "remoteapp" || !res.ManageRemoteApp || strings.TrimSpace(res.RemoteAppPath) == "" {
|
|
continue
|
|
}
|
|
if !a.agentInFarmLocked(agentID, rec, res.FarmID) {
|
|
continue
|
|
}
|
|
alias := remoteAppAlias(res.RemoteApp)
|
|
if !validRemoteAppAlias(alias) {
|
|
continue
|
|
}
|
|
out = append(out, model.RemoteAppSpec{
|
|
ResourceID: res.ID, Alias: alias, DisplayName: res.Name, Path: res.RemoteAppPath,
|
|
IconPath: res.RemoteAppIconPath, IconIndex: res.RemoteAppIconIndex,
|
|
CommandLineSetting: res.RemoteAppCommandLine, RequiredCommandLine: res.RemoteAppRequiredArgs,
|
|
ShowInPortal: res.RemoteAppShowInPortal,
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if strings.EqualFold(out[i].Alias, out[j].Alias) {
|
|
return out[i].ResourceID < out[j].ResourceID
|
|
}
|
|
return strings.ToLower(out[i].Alias) < strings.ToLower(out[j].Alias)
|
|
})
|
|
return out
|
|
}
|
|
|
|
func agentResourceReady(rec model.AgentRecord, res *model.Resource) bool {
|
|
if res == nil || res.Kind != "remoteapp" || !res.ManageRemoteApp {
|
|
return true
|
|
}
|
|
alias := remoteAppAlias(res.RemoteApp)
|
|
for _, st := range rec.Snapshot.RemoteApps {
|
|
if st.ResourceID == res.ID || (st.ResourceID == "" && strings.EqualFold(st.Alias, alias)) {
|
|
return st.Published && st.PathExists && st.InSync && strings.TrimSpace(st.Error) == ""
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func resourceID(r *model.Resource) string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
return r.ID
|
|
}
|