Files

1609 lines
58 KiB
Go

package controlplane
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"net/url"
"path"
"sort"
"strconv"
"strings"
"sync"
"time"
webassets "git.ipao.vip/rogee/wx-win-agent/control-plane/web"
)
type ServerConfig struct {
ListenAddr string
DataFile string
AccountDataDir string
NodeTokens map[string]string
WebUsers map[string]string
LeaseTTL time.Duration
HeartbeatTimeout time.Duration
SessionTTL time.Duration
TLSCertFile string
TLSKeyFile string
MTLSClientCAFile string
MTLSRequireNodeCert bool
MTLSRevokedCertsFile string
BackupDir string
BackupCount int
BackupInterval time.Duration
TaskRetention time.Duration
EventRetention time.Duration
AuditRetention time.Duration
AccountRetention time.Duration
AccountMaxShardBytes int64
AccountMaxBatchBytes int64
AccountMaintenanceInterval time.Duration
AIBaseURL string
AIAPIKey string
AIModel string
AITimeout time.Duration
AISchedulerInterval time.Duration
AIProvider AIProvider
}
func DefaultServerConfig() ServerConfig {
return ServerConfig{
ListenAddr: "127.0.0.1:8090",
DataFile: "control-plane-data.json",
NodeTokens: map[string]string{},
WebUsers: map[string]string{},
LeaseTTL: 30 * time.Second,
HeartbeatTimeout: 45 * time.Second,
SessionTTL: 8 * time.Hour,
BackupCount: 7,
BackupInterval: 5 * time.Minute,
TaskRetention: 30 * 24 * time.Hour,
EventRetention: 30 * 24 * time.Hour,
AuditRetention: 90 * 24 * time.Hour,
AccountRetention: 30 * 24 * time.Hour,
AccountMaxShardBytes: 512 * 1024 * 1024,
AccountMaxBatchBytes: maxDataBatchBytes,
AccountMaintenanceInterval: 5 * time.Minute,
AIModel: "gpt-4o-mini",
AITimeout: 60 * time.Second,
AISchedulerInterval: 5 * time.Second,
}
}
type Server struct {
config ServerConfig
store *Store
accountStores *AccountStoreManager
tlsConfig *tls.Config
nodeTokenHashes map[string][32]byte
userPasswords map[string][32]byte
sessionMu sync.Mutex
sessions map[string]session
aiProvider AIProvider
aiCtx context.Context
aiCancel context.CancelFunc
aiQueue chan string
aiWG sync.WaitGroup
closeOnce sync.Once
closeErr error
}
type session struct {
Username string
Expires time.Time
}
type requestError struct {
status int
code string
message string
}
func (e requestError) Error() string { return e.message }
func NewServer(config ServerConfig) (*Server, error) {
defaults := DefaultServerConfig()
if config.ListenAddr == "" {
config.ListenAddr = defaults.ListenAddr
}
if config.DataFile == "" {
config.DataFile = defaults.DataFile
}
if config.AccountDataDir == "" {
config.AccountDataDir = config.DataFile + ".accounts"
}
if config.BackupDir == "" {
config.BackupDir = config.DataFile + ".backups"
}
if config.BackupCount == 0 {
config.BackupCount = defaults.BackupCount
}
if config.BackupInterval == 0 {
config.BackupInterval = defaults.BackupInterval
}
if config.TaskRetention == 0 {
config.TaskRetention = defaults.TaskRetention
}
if config.EventRetention == 0 {
config.EventRetention = defaults.EventRetention
}
if config.AuditRetention == 0 {
config.AuditRetention = defaults.AuditRetention
}
if config.AccountRetention == 0 {
config.AccountRetention = defaults.AccountRetention
}
if config.AccountMaxShardBytes == 0 {
config.AccountMaxShardBytes = defaults.AccountMaxShardBytes
}
if config.AccountMaxBatchBytes == 0 {
config.AccountMaxBatchBytes = defaults.AccountMaxBatchBytes
}
if config.AccountMaintenanceInterval == 0 {
config.AccountMaintenanceInterval = defaults.AccountMaintenanceInterval
}
if config.LeaseTTL <= 0 {
config.LeaseTTL = defaults.LeaseTTL
}
if config.HeartbeatTimeout <= 0 {
config.HeartbeatTimeout = defaults.HeartbeatTimeout
}
if config.SessionTTL <= 0 {
config.SessionTTL = defaults.SessionTTL
}
if config.AIModel == "" {
config.AIModel = defaults.AIModel
}
if config.AITimeout <= 0 {
config.AITimeout = defaults.AITimeout
}
if config.AISchedulerInterval <= 0 {
config.AISchedulerInterval = defaults.AISchedulerInterval
}
if config.AIProvider == nil && config.AIBaseURL != "" {
config.AIProvider = &OpenAICompatibleProvider{BaseURL: config.AIBaseURL, APIKey: config.AIAPIKey, Model: config.AIModel}
}
if config.NodeTokens == nil {
config.NodeTokens = map[string]string{}
}
if config.WebUsers == nil {
config.WebUsers = map[string]string{}
}
if config.BackupCount < 0 || config.BackupInterval < 0 || config.TaskRetention < 0 || config.EventRetention < 0 || config.AuditRetention < 0 || config.AccountRetention < 0 || config.AccountMaxShardBytes < 0 || config.AccountMaxBatchBytes < 0 || config.AccountMaintenanceInterval < 0 || config.AITimeout < 0 || config.AISchedulerInterval < 0 {
return nil, errors.New("backup count, account budgets, and retention settings must be non-negative")
}
tlsConfig, err := newTLSConfig(config)
if err != nil {
return nil, err
}
store, err := OpenStore(config.DataFile, StoreOptions{
BackupDir: config.BackupDir, BackupCount: config.BackupCount, BackupInterval: config.BackupInterval,
TaskRetention: config.TaskRetention, EventRetention: config.EventRetention, AuditRetention: config.AuditRetention,
})
if err != nil {
return nil, err
}
accountStores, err := OpenAccountStoreManager(config.AccountDataDir, AccountStoreManagerOptions{
Retention: config.AccountRetention,
MaxShardBytes: config.AccountMaxShardBytes,
MaxBatchBytes: config.AccountMaxBatchBytes,
})
if err != nil {
_ = store.Close()
return nil, err
}
aiCtx, aiCancel := context.WithCancel(context.Background())
s := &Server{
config: config,
store: store,
accountStores: accountStores,
tlsConfig: tlsConfig,
nodeTokenHashes: map[string][32]byte{},
userPasswords: map[string][32]byte{},
sessions: map[string]session{},
aiProvider: config.AIProvider,
aiCtx: aiCtx,
aiCancel: aiCancel,
aiQueue: make(chan string, 1000),
}
for nodeID, token := range config.NodeTokens {
if validIdentifier(nodeID, 200) && token != "" {
s.nodeTokenHashes[nodeID] = sha256.Sum256([]byte(token))
}
}
for username, password := range config.WebUsers {
if username != "" && password != "" {
s.userPasswords[username] = sha256.Sum256([]byte(password))
}
}
s.aiWG.Add(3)
go s.aiWorker()
go s.aiScheduler()
go s.accountMaintenance()
return s, nil
}
func (s *Server) Handler() http.Handler { return s }
func (s *Server) ListenAndServe(ctx context.Context) error {
server := &http.Server{Addr: s.config.ListenAddr, Handler: s, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
defer func() { _ = s.Close() }()
var err error
if s.tlsConfig != nil {
server.TLSConfig = s.tlsConfig
err = server.ListenAndServeTLS("", "")
} else {
err = server.ListenAndServe()
}
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
func (s *Server) accountMaintenance() {
defer s.aiWG.Done()
run := func() {
_, _ = s.accountStores.RunMaintenance(s.aiCtx, time.Now().UTC())
}
run()
interval := s.config.AccountMaintenanceInterval
if interval <= 0 {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-s.aiCtx.Done():
return
case <-ticker.C:
run()
}
}
}
// Close releases the active/passive store lock and stops background work.
func (s *Server) Close() error {
s.closeOnce.Do(func() {
s.aiCancel()
s.aiWG.Wait()
s.closeErr = errors.Join(s.accountStores.Close(), s.store.Close())
})
return s.closeErr
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
correlationID := r.Header.Get("X-Correlation-Id")
if !validIdentifier(correlationID, 128) {
correlationID = randomID()
}
w.Header().Set("X-Correlation-Id", correlationID)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
var err error
switch {
case r.URL.Path == "/" && r.Method == http.MethodGet:
s.serveFrontend(w, "dist/index.html")
return
case strings.HasPrefix(r.URL.Path, "/assets/") && r.Method == http.MethodGet:
s.serveFrontend(w, path.Join("dist", strings.TrimPrefix(r.URL.Path, "/")))
return
case r.URL.Path == "/healthz" && r.Method == http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "protocol_version": ProtocolVersion, "correlation_id": correlationID})
return
case r.URL.Path == "/readyz" && r.Method == http.MethodGet:
err = s.ready(w, correlationID)
case r.URL.Path == "/v1/auth/login" && r.Method == http.MethodPost:
err = s.login(w, r, correlationID)
case r.URL.Path == "/v1/nodes" && r.Method == http.MethodGet:
err = s.listNodes(w, r)
case r.URL.Path == "/v1/nodes/register" && r.Method == http.MethodPost:
err = s.registerNode(w, r, correlationID)
case r.URL.Path == "/v1/data/batches" && r.Method == http.MethodPost:
err = s.ingestDataBatch(w, r, correlationID)
case strings.HasPrefix(r.URL.Path, "/v1/data/accounts/"):
err = s.dataRoute(w, r, correlationID)
case strings.HasPrefix(r.URL.Path, "/v1/nodes/"):
err = s.nodeRoute(w, r, correlationID)
case strings.HasPrefix(r.URL.Path, "/v1/reads/"):
err = s.readRoute(w, r, correlationID)
case strings.HasPrefix(r.URL.Path, "/v1/ai/"):
err = s.aiRoute(w, r, correlationID)
case r.URL.Path == "/v1/tasks" || strings.HasPrefix(r.URL.Path, "/v1/tasks/"):
err = s.taskRoute(w, r, correlationID)
case r.URL.Path == "/v1/events":
err = s.eventRoute(w, r, correlationID)
case r.URL.Path == "/v1/audit" && r.Method == http.MethodGet:
err = s.auditRoute(w, r, correlationID)
default:
err = requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
if err != nil {
writeError(w, err, correlationID)
}
}
func (s *Server) ready(w http.ResponseWriter, correlationID string) error {
if err := s.store.Read(func(PersistedState) error { return nil }); err != nil {
return requestError{status: http.StatusServiceUnavailable, code: "NotReady", message: "The control plane store is not ready."}
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "protocol_version": ProtocolVersion, "correlation_id": correlationID})
return nil
}
func (s *Server) login(w http.ResponseWriter, r *http.Request, correlationID string) error {
var request struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &request, 8*1024); err != nil {
return err
}
password, exists := s.userPasswords[request.Username]
provided := sha256.Sum256([]byte(request.Password))
if !exists || subtle.ConstantTimeCompare(password[:], provided[:]) != 1 {
return requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Authentication failed."}
}
token := randomID()
s.sessionMu.Lock()
s.sessions[token] = session{Username: request.Username, Expires: time.Now().UTC().Add(s.config.SessionTTL)}
s.sessionMu.Unlock()
_ = s.appendAudit(request.Username, "login", "session", correlationID, "success")
writeJSON(w, http.StatusOK, map[string]any{"access_token": token, "token_type": "Bearer", "expires_in": int(s.config.SessionTTL.Seconds()), "correlation_id": correlationID})
return nil
}
func (s *Server) registerNode(w http.ResponseWriter, r *http.Request, correlationID string) error {
nodeID, err := s.authenticateNode(r)
if err != nil {
return err
}
var request NodeRegistration
if err := decodeJSON(r, &request, 128*1024); err != nil {
return err
}
if request.NodeID != nodeID {
return requestError{status: http.StatusForbidden, code: "NodeIdentityMismatch", message: "The token is not assigned to this node."}
}
if (request.ConnectionID != "" && !validIdentifier(request.ConnectionID, 200)) || !validIdentifier(request.AgentVersion, 80) || request.ProtocolVersion != ProtocolVersion || len(request.Capabilities) > 100 || !validCapabilities(request.Capabilities) || !validAccountSummaries(request.Accounts) {
return requestError{status: http.StatusBadRequest, code: "InvalidRegistration", message: "Node registration is invalid."}
}
if err := s.registerDataAccounts(nodeID, request); err != nil {
if errors.Is(err, ErrAccountBindingConflict) {
return requestError{status: http.StatusConflict, code: "AccountBindingConflict", message: "The registered account source conflicts with an existing binding."}
}
return requestError{status: http.StatusInternalServerError, code: "AccountRegistrationFailed", message: "The platform could not register account data storage."}
}
now := time.Now().UTC()
if err := s.store.Mutate(func(state *PersistedState) error {
node := state.Nodes[nodeID]
if node.NodeID != "" && node.ConnectionID != request.ConnectionID {
quarantineNodeTasks(state, nodeID, now)
}
node.NodeID = nodeID
node.ConnectionID = request.ConnectionID
node.AgentVersion = request.AgentVersion
node.ProtocolVersion = request.ProtocolVersion
node.Capabilities = append([]string(nil), request.Capabilities...)
node.Status = NodeOnline
node.LastHeartbeatAt = &now
node.ReportingConfigVersion = request.ReportingConfigVersion
node.Accounts = append([]AccountSummary(nil), request.Accounts...)
node.LastCorrelationID = correlationID
state.Nodes[nodeID] = node
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "node.register", nodeID, correlationID, "success", now)
return nil
}); err != nil {
return requestError{status: http.StatusInternalServerError, code: "PersistenceFailed", message: "The control plane could not persist the node registration."}
}
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, ConnectionID: request.ConnectionID, Status: NodeOnline, Authenticated: true, CorrelationID: correlationID})
return nil
}
func (s *Server) nodeRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
parts := pathParts(r.URL.Path)
if len(parts) < 3 || parts[0] != "v1" || parts[1] != "nodes" {
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
nodeID, err := s.authenticateNode(r)
if err != nil {
return err
}
if nodeID != parts[2] {
return requestError{status: http.StatusForbidden, code: "NodeIdentityMismatch", message: "The token is not assigned to this node."}
}
if len(parts) == 3 && r.Method == http.MethodGet {
return s.nodeDetails(w, nodeID, correlationID)
}
if len(parts) < 4 {
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
switch parts[3] {
case "heartbeat":
if r.Method != http.MethodPost || len(parts) != 4 {
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."}
}
return s.heartbeat(w, r, nodeID, correlationID)
case "tasks":
return s.nodeTaskRoute(w, r, nodeID, parts[4:], correlationID)
case "events":
if r.Method != http.MethodPost || len(parts) != 4 {
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."}
}
return s.ingestEvent(w, r, nodeID, correlationID)
case "data":
return s.nodeDataRoute(w, r, nodeID, parts[4:], correlationID)
default:
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
}
func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) error {
if _, err := s.authenticateWeb(r); err != nil {
return err
}
var nodes []Node
if err := s.store.Mutate(func(state *PersistedState) error {
now := time.Now().UTC()
for nodeID, value := range state.Nodes {
node := value
nodeChanged := false
if node.LastHeartbeatAt == nil || now.Sub(*node.LastHeartbeatAt) > s.config.HeartbeatTimeout {
if node.Status != NodeOffline {
node.Status = NodeOffline
nodeChanged = true
}
}
if nodeChanged {
quarantineNodeTasks(state, nodeID, now)
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "node.offline", nodeID, "", "success", now)
state.Nodes[nodeID] = node
}
nodes = append(nodes, node)
}
return nil
}); err != nil {
return err
}
sort.Slice(nodes, func(i, j int) bool { return nodes[i].NodeID < nodes[j].NodeID })
writeJSON(w, http.StatusOK, map[string]any{"nodes": nodes})
return nil
}
func (s *Server) nodeDetails(w http.ResponseWriter, nodeID, _ string) error {
var node Node
if err := s.store.Read(func(state PersistedState) error {
value, ok := state.Nodes[nodeID]
if !ok {
return requestError{status: http.StatusNotFound, code: "NodeNotFound", message: "Node was not registered."}
}
node = value
return nil
}); err != nil {
return err
}
writeJSON(w, http.StatusOK, node)
return nil
}
func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request, nodeID, correlationID string) error {
var request Heartbeat
if err := decodeJSON(r, &request, 32*1024); err != nil {
return err
}
if request.NodeID != nodeID || (request.ConnectionID != "" && !validIdentifier(request.ConnectionID, 200)) || request.ProtocolVersion != ProtocolVersion || !validIdentifier(request.AgentVersion, 80) || !validNodeStatus(request.NodeStatus) || request.QueueLength < 0 || request.QueueLength > 100000 {
return requestError{status: http.StatusBadRequest, code: "InvalidHeartbeat", message: "Heartbeat is invalid."}
}
now := time.Now().UTC()
if err := s.store.Mutate(func(state *PersistedState) error {
node := state.Nodes[nodeID]
if node.NodeID == "" {
return requestError{status: http.StatusConflict, code: "NodeNotRegistered", message: "Register the node before sending a heartbeat."}
}
if node.ConnectionID != request.ConnectionID {
return requestError{status: http.StatusConflict, code: "StaleConnection", message: "The heartbeat belongs to an older Client connection."}
}
node.AgentVersion = request.AgentVersion
node.ProtocolVersion = request.ProtocolVersion
node.Status = request.NodeStatus
node.WechatRunning = request.WechatRunning
node.WechatLoggedIn = request.WechatLoggedIn
node.SessionLocked = request.SessionLocked
node.ActiveAccountID = request.ActiveAccountID
node.QueueLength = request.QueueLength
node.ReportingConfigVersion = request.ReportingConfigVersion
node.LastErrorCode = request.LastErrorCode
node.LastHeartbeatAt = &now
node.LastCorrelationID = correlationID
state.Nodes[nodeID] = node
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "node.heartbeat", nodeID, correlationID, "success", now)
return nil
}); err != nil {
return err
}
writeJSON(w, http.StatusOK, NodeResponse{NodeID: nodeID, ConnectionID: request.ConnectionID, Status: request.NodeStatus, LastHeartbeatAt: &now, CorrelationID: correlationID})
return nil
}
func (s *Server) nodeTaskRoute(w http.ResponseWriter, r *http.Request, nodeID string, parts []string, correlationID string) error {
if len(parts) == 0 && r.Method == http.MethodGet {
return s.pollTasks(w, r, nodeID, correlationID)
}
if len(parts) != 2 || r.Method != http.MethodPost {
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
taskID := parts[0]
switch parts[1] {
case "ack":
return s.ackTask(w, r, nodeID, taskID, correlationID)
case "start":
return s.startTask(w, r, nodeID, taskID, correlationID)
case "renew":
return s.renewTask(w, r, nodeID, taskID, correlationID)
case "result":
return s.recordTaskResult(w, r, nodeID, taskID, correlationID)
default:
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
}
func (s *Server) pollTasks(w http.ResponseWriter, r *http.Request, nodeID, correlationID string) error {
waitSeconds := queryWaitSeconds(r.URL.Query().Get("wait_seconds"))
deadline := time.Now().Add(time.Duration(waitSeconds) * time.Second)
for {
tasks, err := s.claimTasks(r, nodeID, correlationID)
if err != nil {
return err
}
if len(tasks) > 0 || waitSeconds == 0 || !time.Now().Before(deadline) {
writeJSON(w, http.StatusOK, TaskBatch{Tasks: tasks})
return nil
}
select {
case <-r.Context().Done():
return nil
case <-time.After(250 * time.Millisecond):
}
}
}
func (s *Server) claimTasks(r *http.Request, nodeID, correlationID string) ([]Task, error) {
accountID := r.URL.Query().Get("account_id")
if accountID != "" && !validIdentifier(accountID, 200) {
return nil, requestError{status: http.StatusBadRequest, code: "InvalidAccount", message: "account_id is invalid."}
}
now := time.Now().UTC()
var tasks []Task
if err := s.store.Mutate(func(state *PersistedState) error {
for taskID, value := range state.Tasks {
task := value
if task.NodeID != nodeID || (accountID != "" && task.AccountID != accountID) || terminal(task.Status) {
continue
}
changed := false
if task.NotAfter != nil && !now.Before(*task.NotAfter) {
if task.Status == TaskPending {
task.Status = TaskExpired
task.StateVersion++
task.UpdatedAt = now
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
changed = true
} else if task.Status == TaskAccepted || task.Status == TaskRunning {
markUnconfirmed(&task, "TaskDeadlineReached", now)
changed = true
}
}
if terminal(task.Status) {
if changed {
state.Tasks[taskID] = task
}
continue
}
if task.CancelRequestedAt != nil && task.Status == TaskPending && !leaseActive(task, now) {
task.Status = TaskCancelled
task.StateVersion++
task.UpdatedAt = now
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
changed = true
}
if terminal(task.Status) {
if changed {
state.Tasks[taskID] = task
}
continue
}
if task.Status == TaskPending {
if leaseActive(task, now) && task.LeaseOwner != nodeID {
continue
}
if !leaseActive(task, now) {
task.LeaseGeneration++
expires := now.Add(s.config.LeaseTTL)
task.LeaseExpiresAt = &expires
task.LeaseOwner = nodeID
task.UpdatedAt = now
task.LastCorrelationID = correlationID
changed = true
}
}
if task.Status == TaskAccepted || task.Status == TaskRunning {
if task.LeaseOwner != nodeID || !leaseActive(task, now) {
if task.LeaseOwner == nodeID && !leaseActive(task, now) {
markUnconfirmed(&task, "LeaseExpired", now)
changed = true
}
if changed {
state.Tasks[taskID] = task
}
continue
}
}
if changed {
state.Tasks[taskID] = task
}
if task.Status == TaskPending || task.Status == TaskAccepted || task.Status == TaskRunning {
tasks = append(tasks, task)
}
}
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.poll", nodeID, correlationID, "success", now)
return nil
}); err != nil {
return nil, err
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].CreatedAt.Before(tasks[j].CreatedAt) })
return tasks, nil
}
func (s *Server) ackTask(w http.ResponseWriter, r *http.Request, nodeID, taskID, correlationID string) error {
var request TaskAck
if err := decodeJSON(r, &request, 8*1024); err != nil {
return err
}
if request.TaskID != taskID {
return requestError{status: http.StatusBadRequest, code: "TaskIdentityMismatch", message: "Task identity does not match the path."}
}
var task Task
err := s.store.Mutate(func(state *PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok || value.NodeID != nodeID {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
task = value
if task.AccountID != request.AccountID {
return requestError{status: http.StatusForbidden, code: "AccountMismatch", message: "Task account does not match."}
}
if terminal(task.Status) {
return nil
}
if task.LeaseGeneration != request.LeaseGeneration || task.LeaseOwner != nodeID || !leaseActive(task, time.Now().UTC()) {
return requestError{status: http.StatusConflict, code: "LeaseMismatch", message: "The task lease is no longer valid."}
}
if task.Status != TaskPending {
if task.Status == TaskAccepted {
return nil
}
return requestError{status: http.StatusConflict, code: "InvalidTaskState", message: "The task cannot be accepted in its current state."}
}
if task.CancelRequestedAt != nil {
task.Status = TaskCancelled
task.StateVersion++
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
} else {
task.Status = TaskAccepted
task.StateVersion++
}
task.UpdatedAt = time.Now().UTC()
task.LastCorrelationID = correlationID
state.Tasks[taskID] = task
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.accept", taskID, correlationID, "success", task.UpdatedAt)
return nil
})
if err != nil {
return err
}
writeJSON(w, http.StatusOK, task)
return nil
}
func (s *Server) startTask(w http.ResponseWriter, r *http.Request, nodeID, taskID, correlationID string) error {
var request TaskAck
if err := decodeJSON(r, &request, 8*1024); err != nil {
return err
}
if request.TaskID != taskID {
return requestError{status: http.StatusBadRequest, code: "TaskIdentityMismatch", message: "Task identity does not match the path."}
}
var task Task
err := s.store.Mutate(func(state *PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok || value.NodeID != nodeID {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
task = value
if task.AccountID != request.AccountID || task.LeaseGeneration != request.LeaseGeneration || task.LeaseOwner != nodeID {
return requestError{status: http.StatusConflict, code: "LeaseMismatch", message: "The task lease is no longer valid."}
}
now := time.Now().UTC()
if terminal(task.Status) {
return nil
}
if task.Status != TaskAccepted {
return requestError{status: http.StatusConflict, code: "InvalidTaskState", message: "The task must be accepted before execution."}
}
if task.CancelRequestedAt != nil {
task.Status = TaskCancelled
task.StateVersion++
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
} else if task.NotAfter != nil && !now.Before(*task.NotAfter) {
task.Status = TaskExpired
task.StateVersion++
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
} else if !leaseActive(task, now) {
return requestError{status: http.StatusConflict, code: "LeaseExpired", message: "The task lease expired before execution started."}
} else {
task.Status = TaskRunning
task.StateVersion++
}
task.UpdatedAt = now
task.LastCorrelationID = correlationID
state.Tasks[taskID] = task
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.start", taskID, correlationID, "success", now)
return nil
})
if err != nil {
return err
}
writeJSON(w, http.StatusOK, task)
return nil
}
func (s *Server) renewTask(w http.ResponseWriter, r *http.Request, nodeID, taskID, correlationID string) error {
var request TaskAck
if err := decodeJSON(r, &request, 8*1024); err != nil {
return err
}
if request.TaskID != taskID {
return requestError{status: http.StatusBadRequest, code: "TaskIdentityMismatch", message: "Task identity does not match the path."}
}
var task Task
if err := s.store.Mutate(func(state *PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok || value.NodeID != nodeID {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
task = value
if task.AccountID != request.AccountID || task.LeaseGeneration != request.LeaseGeneration || task.LeaseOwner != nodeID {
return requestError{status: http.StatusConflict, code: "LeaseMismatch", message: "The task lease is no longer valid."}
}
if task.CancelRequestedAt != nil {
return requestError{status: http.StatusConflict, code: "CancelRequested", message: "The task lease cannot be renewed after cancellation was requested."}
}
now := time.Now().UTC()
if task.Status != TaskAccepted && task.Status != TaskRunning || !leaseActive(task, now) {
return requestError{status: http.StatusConflict, code: "LeaseExpired", message: "The task lease is no longer renewable."}
}
expires := now.Add(s.config.LeaseTTL)
task.LeaseExpiresAt = &expires
task.StateVersion++
task.UpdatedAt = now
task.LastCorrelationID = correlationID
state.Tasks[taskID] = task
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.renew", taskID, correlationID, "success", now)
return nil
}); err != nil {
return err
}
writeJSON(w, http.StatusOK, task)
return nil
}
func (s *Server) recordTaskResult(w http.ResponseWriter, r *http.Request, nodeID, taskID, correlationID string) error {
var result TaskResult
if err := decodeJSON(r, &result, MaxTaskResultBytes+32*1024); err != nil {
return err
}
if result.TaskID != taskID || !terminal(result.Status) {
return requestError{status: http.StatusBadRequest, code: "InvalidTaskResult", message: "Task result or terminal status is invalid."}
}
if len(result.Content) > MaxTaskResultBytes {
return requestError{status: http.StatusRequestEntityTooLarge, code: "ContentTooLarge", message: "The task result content is too large."}
}
var task Task
err := s.store.Mutate(func(state *PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok || value.NodeID != nodeID {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
task = value
if task.AccountID != result.AccountID {
return requestError{status: http.StatusForbidden, code: "AccountMismatch", message: "Task account does not match."}
}
if hasTaskContent(result.Content) && !isReadTaskKind(task.Kind) {
return requestError{status: http.StatusForbidden, code: "ContentNotAllowed", message: "Only read task results may contain content."}
}
if terminal(task.Status) {
if task.Result != nil && task.Result.Status == result.Status && task.Result.ErrorCode == result.ErrorCode {
return nil
}
return requestError{status: http.StatusConflict, code: "TerminalState", message: "The task already has a different terminal result."}
}
if task.LeaseGeneration != result.LeaseGeneration || task.LeaseOwner != nodeID {
return requestError{status: http.StatusConflict, code: "LeaseMismatch", message: "The result belongs to an expired task lease."}
}
if task.Status != TaskAccepted && task.Status != TaskRunning {
return requestError{status: http.StatusConflict, code: "InvalidTaskState", message: "The task is not awaiting a final result."}
}
result.CorrelationID = firstNonEmpty(result.CorrelationID, correlationID)
task.Result = &result
task.Status = result.Status
task.StateVersion++
task.UpdatedAt = time.Now().UTC()
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
task.LastCorrelationID = correlationID
state.Tasks[taskID] = task
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "task.result", taskID, correlationID, "success", task.UpdatedAt)
return nil
})
if err != nil {
return err
}
if task.Kind == "read-messages" {
go s.handleAITaskResult(taskID)
}
writeJSON(w, http.StatusOK, task)
return nil
}
func (s *Server) readRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
username, err := s.authenticateWeb(r)
if err != nil {
return err
}
parts := pathParts(r.URL.Path)
if len(parts) != 3 || parts[0] != "v1" || parts[1] != "reads" || r.Method != http.MethodPost {
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
kind := "read-" + parts[2]
if !isReadTaskKind(kind) {
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
var request ReadTaskSubmission
if err := decodeJSON(r, &request, 64*1024); err != nil {
return err
}
payload, err := readTaskPayload(kind, request)
if err != nil {
return err
}
return s.createTaskSubmission(w, TaskSubmission{
NodeID: request.NodeID, AccountID: request.AccountID, Kind: kind,
IdempotencyKey: request.IdempotencyKey, Payload: payload, NotAfter: request.NotAfter,
}, username, correlationID)
}
type readSessionsPayload struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type readContactsPayload struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Contains string `json:"contains,omitempty"`
GroupsOnly bool `json:"groups_only"`
}
type readMessagesPayload struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
ChatID string `json:"chat_id"`
IncludeContent bool `json:"include_content"`
}
func isReadTaskKind(kind string) bool {
switch kind {
case "read-sessions", "read-contacts", "read-messages":
return true
default:
return false
}
}
func readTaskPayload(kind string, request ReadTaskSubmission) (jsonRaw, error) {
limit := request.Limit
if limit == 0 {
limit = 50
}
if limit < 1 || limit > 200 || request.Offset < 0 {
return nil, requestError{status: http.StatusBadRequest, code: "InvalidPagination", message: "limit must be 1..200 and offset must be non-negative."}
}
if request.Contains != "" && !validIdentifier(request.Contains, 200) {
return nil, requestError{status: http.StatusBadRequest, code: "InvalidReadRequest", message: "contains is invalid."}
}
switch kind {
case "read-sessions":
if request.Contains != "" || request.GroupsOnly != nil || request.ChatID != "" || request.IncludeContent {
return nil, requestError{status: http.StatusBadRequest, code: "InvalidReadRequest", message: "The sessions read request contains unsupported fields."}
}
return json.Marshal(readSessionsPayload{Limit: limit, Offset: request.Offset})
case "read-contacts":
if request.GroupsOnly == nil || request.ChatID != "" || request.IncludeContent {
return nil, requestError{status: http.StatusBadRequest, code: "InvalidReadRequest", message: "contacts requires groups_only and does not accept chat_id or include_content."}
}
return json.Marshal(readContactsPayload{Limit: limit, Offset: request.Offset, Contains: request.Contains, GroupsOnly: *request.GroupsOnly})
case "read-messages":
if !validIdentifier(request.ChatID, 512) || request.GroupsOnly != nil || request.Contains != "" {
return nil, requestError{status: http.StatusBadRequest, code: "InvalidReadRequest", message: "messages requires a stable chat_id and does not accept contains or groups_only."}
}
return json.Marshal(readMessagesPayload{Limit: limit, Offset: request.Offset, ChatID: request.ChatID, IncludeContent: request.IncludeContent})
default:
return nil, requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
}
func validTaskPayload(kind string, payload jsonRaw) bool {
if kind == "send-text" {
return validSendTextPayload(payload)
}
if kind == "sync-data" {
var value struct {
StreamKey string `json:"stream_key"`
Reason string `json:"reason"`
}
return decodeRaw(payload, &value) && value.StreamKey == "messages" && (value.Reason == "" || validIdentifier(value.Reason, 80))
}
if !isReadTaskKind(kind) || len(payload) == 0 || len(payload) > 64*1024 || !json.Valid(payload) {
return false
}
switch kind {
case "read-sessions":
var value readSessionsPayload
return decodeRaw(payload, &value) && validPagination(value.Limit, value.Offset)
case "read-contacts":
var value readContactsPayload
return decodeRaw(payload, &value) && validPagination(value.Limit, value.Offset) && (value.Contains == "" || validIdentifier(value.Contains, 200))
case "read-messages":
var value readMessagesPayload
return decodeRaw(payload, &value) && validPagination(value.Limit, value.Offset) && validIdentifier(value.ChatID, 512)
default:
return false
}
}
func decodeRaw(payload jsonRaw, target any) bool {
decoder := json.NewDecoder(strings.NewReader(string(payload)))
decoder.DisallowUnknownFields()
if decoder.Decode(target) != nil {
return false
}
var extra any
return decoder.Decode(&extra) == io.EOF
}
func validPagination(limit, offset int) bool { return limit >= 1 && limit <= 200 && offset >= 0 }
func hasTaskContent(content jsonRaw) bool { return len(content) != 0 && string(content) != "null" }
func (s *Server) taskRoute(w http.ResponseWriter, r *http.Request, correlationID string) error {
username, err := s.authenticateWeb(r)
if err != nil {
return err
}
parts := pathParts(r.URL.Path)
if len(parts) == 2 && parts[0] == "v1" && parts[1] == "tasks" {
switch r.Method {
case http.MethodGet:
return s.listTasks(w, r)
case http.MethodPost:
return s.createTask(w, r, username, correlationID)
default:
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."}
}
}
if len(parts) == 3 && r.Method == http.MethodGet {
return s.getTask(w, parts[2])
}
if len(parts) == 4 && parts[3] == "cancel" && r.Method == http.MethodPost {
return s.cancelTask(w, parts[2], username, correlationID)
}
if len(parts) == 4 && parts[3] == "resume" && r.Method == http.MethodPost {
return s.resumeTask(w, parts[2], username, correlationID)
}
return requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}
}
func (s *Server) createTask(w http.ResponseWriter, r *http.Request, username, correlationID string) error {
var request TaskSubmission
if err := decodeJSON(r, &request, 128*1024); err != nil {
return err
}
return s.createTaskSubmission(w, request, username, correlationID)
}
func (s *Server) createTaskSubmission(w http.ResponseWriter, request TaskSubmission, username, correlationID string) error {
if !validIdentifier(request.NodeID, 200) || !validIdentifier(request.AccountID, 200) || !validIdentifier(request.Kind, 80) || !validIdentifier(request.IdempotencyKey, 128) {
return requestError{status: http.StatusBadRequest, code: "InvalidTask", message: "Task identity fields are invalid."}
}
if !validTaskPayload(request.Kind, request.Payload) {
return requestError{status: http.StatusBadRequest, code: "UnsupportedTask", message: "The task kind or payload is not supported."}
}
if request.NotAfter != nil && !request.NotAfter.After(time.Now().UTC()) {
return requestError{status: http.StatusBadRequest, code: "InvalidDeadline", message: "not_after must be in the future."}
}
var response TaskSubmissionResponse
err := s.store.Mutate(func(state *PersistedState) error {
node, registered := state.Nodes[request.NodeID]
if !registered {
return requestError{status: http.StatusConflict, code: "NodeNotReady", message: "The target node is not registered."}
}
if !nodeHasAccount(node, request.AccountID) {
return requestError{status: http.StatusConflict, code: "AccountNotReady", message: "The target account is not currently registered on the node."}
}
payloadKey := string(request.Payload)
for _, existing := range state.Tasks {
if existing.NodeID != request.NodeID || existing.AccountID != request.AccountID || existing.IdempotencyKey != request.IdempotencyKey {
continue
}
if existing.Kind != request.Kind || string(existing.Payload) != payloadKey {
return requestError{status: http.StatusConflict, code: "IdempotencyConflict", message: "The idempotency key is already bound to different task parameters."}
}
response = TaskSubmissionResponse{TaskID: existing.TaskID, Status: existing.Status, Duplicate: true, StateVersion: existing.StateVersion}
return nil
}
now := time.Now().UTC()
status := TaskPending
if !nodeAvailable(node, now, s.config.HeartbeatTimeout) {
status = TaskWaitingForClient
}
task := Task{
TaskID: randomID(), NodeID: request.NodeID, AccountID: request.AccountID, Kind: request.Kind,
IdempotencyKey: request.IdempotencyKey, Payload: append(jsonRaw(nil), request.Payload...), NotAfter: request.NotAfter,
Status: status, StateVersion: 1, CreatedAt: now, UpdatedAt: now, LastCorrelationID: correlationID,
}
state.Tasks[task.TaskID] = task
state.Audit = appendAudit(state.Audit, "user:"+username, "task.create", task.TaskID, correlationID, "success", now)
response = TaskSubmissionResponse{TaskID: task.TaskID, Status: task.Status, Duplicate: false, StateVersion: task.StateVersion}
return nil
})
if err != nil {
return err
}
writeJSON(w, http.StatusAccepted, response)
return nil
}
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) error {
nodeID := r.URL.Query().Get("node_id")
accountID := r.URL.Query().Get("account_id")
limit := queryLimit(r.URL.Query().Get("limit"))
var tasks []Task
if err := s.store.Read(func(state PersistedState) error {
for _, task := range state.Tasks {
if nodeID != "" && task.NodeID != nodeID || accountID != "" && task.AccountID != accountID {
continue
}
tasks = append(tasks, task)
}
return nil
}); err != nil {
return err
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].CreatedAt.After(tasks[j].CreatedAt) })
if len(tasks) > limit {
tasks = tasks[:limit]
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
return nil
}
func (s *Server) getTask(w http.ResponseWriter, taskID string) error {
if !validIdentifier(taskID, 200) {
return requestError{status: http.StatusBadRequest, code: "InvalidTask", message: "Task ID is invalid."}
}
var task Task
if err := s.store.Read(func(state PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
task = value
return nil
}); err != nil {
return err
}
writeJSON(w, http.StatusOK, task)
return nil
}
func (s *Server) cancelTask(w http.ResponseWriter, taskID, username, correlationID string) error {
var task Task
err := s.store.Mutate(func(state *PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
task = value
if terminal(task.Status) {
return nil
}
now := time.Now().UTC()
if task.CancelRequestedAt == nil {
task.CancelRequestedAt = &now
task.StateVersion++
if (task.Status == TaskPending || task.Status == TaskWaitingForClient) && !leaseActive(task, now) {
task.Status = TaskCancelled
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
}
task.UpdatedAt = now
state.Audit = appendAudit(state.Audit, "user:"+username, "task.cancel-request", taskID, correlationID, "success", now)
state.Tasks[taskID] = task
}
return nil
})
if err != nil {
return err
}
writeJSON(w, http.StatusOK, task)
return nil
}
func (s *Server) resumeTask(w http.ResponseWriter, taskID, username, correlationID string) error {
var task Task
err := s.store.Mutate(func(state *PersistedState) error {
value, ok := state.Tasks[taskID]
if !ok {
return requestError{status: http.StatusNotFound, code: "TaskNotFound", message: "Task was not found."}
}
task = value
if terminal(task.Status) {
return requestError{status: http.StatusConflict, code: "TerminalState", message: "The task cannot be resumed after it reached a terminal state."}
}
if task.Status != TaskWaitingForClient {
return requestError{status: http.StatusConflict, code: "InvalidTaskState", message: "Only tasks waiting for a Client can be resumed."}
}
if task.CancelRequestedAt != nil {
return requestError{status: http.StatusConflict, code: "CancelRequested", message: "The task was cancelled and cannot be resumed."}
}
node, exists := state.Nodes[task.NodeID]
now := time.Now().UTC()
if !exists || !nodeAvailable(node, now, s.config.HeartbeatTimeout) || !nodeHasAccount(node, task.AccountID) {
return requestError{status: http.StatusConflict, code: "ClientNotReady", message: "The target Client is not connected with the requested account."}
}
if task.NotAfter != nil && !now.Before(*task.NotAfter) {
task.Status = TaskExpired
task.StateVersion++
task.UpdatedAt = now
} else {
task.Status = TaskPending
task.StateVersion++
task.UpdatedAt = now
task.LastCorrelationID = correlationID
}
state.Tasks[taskID] = task
state.Audit = appendAudit(state.Audit, "user:"+username, "task.resume", taskID, correlationID, "success", now)
return nil
})
if err != nil {
return err
}
writeJSON(w, http.StatusOK, task)
return nil
}
func (s *Server) eventRoute(w http.ResponseWriter, r *http.Request, _ string) error {
if r.Method != http.MethodGet {
return requestError{status: http.StatusMethodNotAllowed, code: "MethodNotAllowed", message: "Method is not allowed."}
}
if _, err := s.authenticateWeb(r); err != nil {
return err
}
return s.listEvents(w, r)
}
func (s *Server) ingestEvent(w http.ResponseWriter, r *http.Request, nodeID, correlationID string) error {
var event MessageEvent
if err := decodeJSON(r, &event, 64*1024); err != nil {
return err
}
if event.NodeID != nodeID || !validIdentifier(event.AccountID, 200) || !validIdentifier(event.ChatID, 512) || event.EventSeq <= 0 || !validIdentifier(event.EventType, 80) || event.OccurredAt.IsZero() || event.ConfigVersion <= 0 || event.AuthorizationVersion <= 0 {
return requestError{status: http.StatusBadRequest, code: "InvalidEvent", message: "Event identity or authorization metadata is invalid."}
}
if event.ChatType != ChatGroup && event.ChatType != ChatPrivate {
return requestError{status: http.StatusBadRequest, code: "InvalidChatType", message: "Only group and private chat events are supported."}
}
if event.EventType != "message" {
return requestError{status: http.StatusBadRequest, code: "UnsupportedEvent", message: "Only message events are supported in v1."}
}
if len(event.Content) > 16*1024 {
return requestError{status: http.StatusBadRequest, code: "EventTooLarge", message: "Event content is too large."}
}
if !event.Authorized {
return requestError{status: http.StatusForbidden, code: "ReportingNotAuthorized", message: "The node did not authorize this event."}
}
contentHash := eventHash(event)
var receipt EventReceipt
var stored StoredEvent
err := s.store.Mutate(func(state *PersistedState) error {
for _, existing := range state.Events {
if existing.NodeID != event.NodeID || existing.AccountID != event.AccountID || existing.ChatID != event.ChatID || existing.EventSeq != event.EventSeq {
continue
}
if existing.ContentHash != contentHash {
return requestError{status: http.StatusConflict, code: "EventIdempotencyConflict", message: "The event sequence is already bound to different content."}
}
receipt = EventReceipt{Accepted: true, Duplicate: true, EventID: existing.EventID}
return nil
}
now := time.Now().UTC()
stored = StoredEvent{MessageEvent: event, EventID: randomID(), ContentHash: contentHash, ReceivedAt: now}
state.Events = append(state.Events, stored)
if len(state.Events) > 10000 {
state.Events = state.Events[len(state.Events)-10000:]
}
state.Audit = appendAudit(state.Audit, "node:"+nodeID, "event.accept", event.AccountID+":"+event.ChatID, correlationID, "success", now)
receipt = EventReceipt{Accepted: true, EventID: stored.EventID}
return nil
})
if err != nil {
return err
}
if !receipt.Duplicate {
go s.enqueueEventRuns(stored)
}
writeJSON(w, http.StatusAccepted, receipt)
return nil
}
func (s *Server) listEvents(w http.ResponseWriter, r *http.Request) error {
query := r.URL.Query()
nodeID, accountID, chatID := query.Get("node_id"), query.Get("account_id"), query.Get("chat_id")
limit := queryLimit(query.Get("limit"))
var events []StoredEvent
if err := s.store.Read(func(state PersistedState) error {
for index := len(state.Events) - 1; index >= 0 && len(events) < limit; index-- {
event := state.Events[index]
if nodeID != "" && event.NodeID != nodeID || accountID != "" && event.AccountID != accountID || chatID != "" && event.ChatID != chatID {
continue
}
events = append(events, event)
}
return nil
}); err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"events": events})
return nil
}
func (s *Server) auditRoute(w http.ResponseWriter, r *http.Request, _ string) error {
if _, err := s.authenticateWeb(r); err != nil {
return err
}
limit := queryLimit(r.URL.Query().Get("limit"))
var audit []AuditEntry
if err := s.store.Read(func(state PersistedState) error {
for index := len(state.Audit) - 1; index >= 0 && len(audit) < limit; index-- {
audit = append(audit, state.Audit[index])
}
return nil
}); err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"audit": audit})
return nil
}
func (s *Server) authenticateNode(r *http.Request) (string, error) {
if !s.clientCertificateAllowed(r) {
return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Node certificate authentication failed."}
}
token := bearerToken(r)
if token == "" {
return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Node authentication is required."}
}
provided := sha256.Sum256([]byte(token))
for nodeID, expected := range s.nodeTokenHashes {
if subtle.ConstantTimeCompare(provided[:], expected[:]) == 1 {
return nodeID, nil
}
}
return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Node authentication failed."}
}
func (s *Server) authenticateWeb(r *http.Request) (string, error) {
token := bearerToken(r)
if token == "" {
return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Web authentication is required."}
}
now := time.Now().UTC()
s.sessionMu.Lock()
value, ok := s.sessions[token]
if ok && !now.Before(value.Expires) {
delete(s.sessions, token)
ok = false
}
s.sessionMu.Unlock()
if !ok {
return "", requestError{status: http.StatusUnauthorized, code: "Unauthorized", message: "Web session is missing or expired."}
}
return value.Username, nil
}
func (s *Server) appendAudit(principal, action, resource, correlationID, outcome string) error {
return s.store.Mutate(func(state *PersistedState) error {
state.Audit = appendAudit(state.Audit, principal, action, resource, correlationID, outcome, time.Now().UTC())
return nil
})
}
func (s *Server) serveFrontend(w http.ResponseWriter, name string) {
if name == "" || name == "." || strings.Contains(name, "..") {
writeError(w, requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}, randomID())
return
}
content, err := fs.ReadFile(webassets.Dist, name)
if err != nil {
writeError(w, requestError{status: http.StatusNotFound, code: "NotFound", message: "Resource was not found."}, randomID())
return
}
contentType := mime.TypeByExtension(path.Ext(name))
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType+"; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}
func decodeJSON(r *http.Request, target any, maxBytes int64) error {
defer r.Body.Close()
decoder := json.NewDecoder(io.LimitReader(r.Body, maxBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return requestError{status: http.StatusBadRequest, code: "InvalidRequest", message: "Request JSON is invalid."}
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return requestError{status: http.StatusBadRequest, code: "InvalidRequest", message: "Request must contain one JSON value."}
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, err error, correlationID string) {
requestErr, ok := err.(requestError)
if !ok {
requestErr = requestError{status: http.StatusInternalServerError, code: "InternalError", message: "Request failed; use the correlation ID for diagnosis."}
}
writeJSON(w, requestErr.status, map[string]any{
"error": map[string]string{"code": requestErr.code, "message": requestErr.message},
"correlation_id": correlationID,
})
}
func pathParts(path string) []string {
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return nil
}
parts := strings.Split(trimmed, "/")
for index, part := range parts {
decoded, err := url.PathUnescape(part)
if err == nil {
parts[index] = decoded
}
}
return parts
}
func bearerToken(r *http.Request) string {
value := r.Header.Get("Authorization")
if len(value) < 8 || !strings.EqualFold(value[:7], "Bearer ") {
return ""
}
return strings.TrimSpace(value[7:])
}
func validIdentifier(value string, max int) bool {
return value != "" && len(value) <= max && !strings.ContainsAny(value, "\r\n\x00")
}
func terminal(status TaskStatus) bool {
switch status {
case TaskSucceeded, TaskFailed, TaskCancelled, TaskExpired, TaskResultUnconfirmed:
return true
default:
return false
}
}
func leaseActive(task Task, now time.Time) bool {
return task.LeaseOwner != "" && task.LeaseExpiresAt != nil && now.Before(*task.LeaseExpiresAt)
}
func markUnconfirmed(task *Task, reason string, now time.Time) {
task.Status = TaskResultUnconfirmed
task.StateVersion++
task.UpdatedAt = now
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
task.Result = &TaskResult{TaskID: task.TaskID, AccountID: task.AccountID, LeaseGeneration: task.LeaseGeneration, Status: TaskResultUnconfirmed, ErrorCode: reason, Message: "The control plane could not prove that the previous executor stopped before the lease expired.", CorrelationID: task.LastCorrelationID}
}
func quarantineNodeTasks(state *PersistedState, nodeID string, now time.Time) {
for taskID, value := range state.Tasks {
if value.NodeID != nodeID || terminal(value.Status) {
continue
}
task := value
switch task.Status {
case TaskPending, TaskWaitingForClient:
if task.Status == TaskWaitingForClient && task.LeaseOwner == "" && task.LeaseExpiresAt == nil {
continue
}
task.Status = TaskWaitingForClient
task.StateVersion++
task.UpdatedAt = now
task.LeaseOwner = ""
task.LeaseExpiresAt = nil
case TaskAccepted, TaskRunning:
markUnconfirmed(&task, "ClientDisconnected", now)
default:
continue
}
state.Tasks[taskID] = task
}
}
func validCapabilities(capabilities []string) bool {
for _, capability := range capabilities {
if !validIdentifier(capability, 80) {
return false
}
}
return true
}
func validAccountSummaries(accounts []AccountSummary) bool {
seen := make(map[string]struct{}, len(accounts))
for _, account := range accounts {
if !validIdentifier(account.AccountID, 200) || account.AllowedGroupCount < 0 || account.AllowedPrivateCount < 0 {
return false
}
if _, exists := seen[account.AccountID]; exists {
return false
}
seen[account.AccountID] = struct{}{}
}
return len(accounts) <= 100
}
func validNodeStatus(status NodeStatus) bool {
switch status {
case NodeRegistered, NodeOnline, NodeDegraded, NodeOffline, NodeSessionLocked, NodeWechatNotRunning, NodeWechatNotLogged:
return true
default:
return false
}
}
func nodeHasAccount(node Node, accountID string) bool {
for _, account := range node.Accounts {
if account.AccountID == accountID && account.Active && account.Verified {
return true
}
}
return false
}
func nodeAvailable(node Node, now time.Time, heartbeatTimeout time.Duration) bool {
return node.Status != NodeOffline && node.LastHeartbeatAt != nil && now.Sub(*node.LastHeartbeatAt) <= heartbeatTimeout
}
func validSendTextPayload(payload jsonRaw) bool {
if len(payload) == 0 || len(payload) > 64*1024 || !json.Valid(payload) {
return false
}
var value struct {
TargetID string `json:"target_id"`
Text string `json:"text"`
Confirmed bool `json:"confirmed"`
}
if json.Unmarshal(payload, &value) != nil {
return false
}
return validIdentifier(value.TargetID, 512) && len(value.Text) > 0 && len(value.Text) <= 4000 && value.Confirmed
}
func eventHash(event MessageEvent) string {
value := struct {
NodeID string `json:"node_id"`
AccountID string `json:"account_id"`
ChatID string `json:"chat_id"`
ChatType ChatType `json:"chat_type"`
EventSeq int64 `json:"event_seq"`
EventType string `json:"event_type"`
OccurredAt time.Time `json:"occurred_at"`
Content string `json:"content"`
ConfigVersion int64 `json:"config_version"`
}{event.NodeID, event.AccountID, event.ChatID, event.ChatType, event.EventSeq, event.EventType, event.OccurredAt, event.Content, event.ConfigVersion}
data, _ := json.Marshal(value)
digest := sha256.Sum256(data)
return hex.EncodeToString(digest[:])
}
func appendAudit(entries []AuditEntry, principal, action, resource, correlationID, outcome string, at time.Time) []AuditEntry {
entries = append(entries, AuditEntry{ID: randomID(), At: at, Principal: principal, Action: action, Resource: resource, CorrelationID: correlationID, Outcome: outcome})
if len(entries) > 10000 {
entries = entries[len(entries)-10000:]
}
return entries
}
func queryLimit(raw string) int {
if value, err := strconv.Atoi(raw); err == nil && value >= 1 && value <= 200 {
return value
}
return 50
}
func queryWaitSeconds(raw string) int {
if value, err := strconv.Atoi(raw); err == nil && value >= 1 && value <= 30 {
return value
}
return 0
}
func firstNonEmpty(value, fallback string) string {
if value != "" {
return value
}
return fallback
}
func randomID() string {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
return base64.RawURLEncoding.EncodeToString(buffer)
}