diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ec0bbfb1..3a7634ac 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -67,12 +67,16 @@ nfpms: - sqlite3 - nftables | iptables file_name_template: '{{ .PackageName }}_{{ trimprefix .Version "v" }}_{{ .Os }}_{{ .Arch }}' + suggests: + - alpamon-pam rpm: dependencies: - zip - sqlite - nftables | iptables file_name_template: '{{ .PackageName }}-{{ trimprefix .Version "v" }}-1.{{ .Os }}.{{ .Arch }}' + suggests: + - alpamon-pam changelog: sort: asc diff --git a/Dockerfiles/ubuntu/22.04/Dockerfile b/Dockerfiles/ubuntu/22.04/Dockerfile index 168afc3a..dfb87822 100644 --- a/Dockerfiles/ubuntu/22.04/Dockerfile +++ b/Dockerfiles/ubuntu/22.04/Dockerfile @@ -1,10 +1,11 @@ FROM golang:1.23 AS builder -# Set golang env +# Automatically set GOARCH to the build-time architecture +ARG TARGETARCH ENV GO111MODULE=on \ CGO_ENABLED=0 \ GOOS=linux \ - GOARCH=amd64 + GOARCH=${TARGETARCH:-amd64} WORKDIR /build @@ -18,7 +19,15 @@ RUN go build -o alpamon ./cmd/alpamon/main.go FROM ubuntu:22.04 -RUN apt-get update && apt-get install -y --no-install-recommends systemd ca-certificates +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + systemd \ + ca-certificates \ + sudo \ + vim \ + build-essential \ + libpam0g-dev \ + libjansson-dev WORKDIR /usr/local/alpamon diff --git a/Dockerfiles/ubuntu/22.04/entrypoint.sh b/Dockerfiles/ubuntu/22.04/entrypoint.sh index 3f45fff7..c87c78f6 100644 --- a/Dockerfiles/ubuntu/22.04/entrypoint.sh +++ b/Dockerfiles/ubuntu/22.04/entrypoint.sh @@ -19,4 +19,9 @@ EOL echo -e "\nThe following configuration file is being used:\n" cat /etc/alpamon/alpamon.conf +echo "[entrypoint] creating /var/run/alpamon..." +mkdir -p /var/run/alpamon +chown root:root /var/run/alpamon +chmod 750 /var/run/alpamon + exec /usr/local/alpamon/alpamon \ No newline at end of file diff --git a/README.md b/README.md index 330e0fbf..cc6862f7 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,45 @@ Download the latest `alpamon` directly from our releases page or install it usin ```bash curl -s https://packagecloud.io/install/repositories/alpacax/alpamon/script.deb.sh?any=true | sudo bash +# Install alpamon (includes PAM module by default) sudo apt-get install alpamon + +# Install without PAM module +sudo apt-get install alpamon --no-install-recommends ``` #### CentOS and RHEL ```bash curl -s https://packagecloud.io/install/repositories/alpacax/alpamon/script.rpm.sh?any=true | sudo bash +# Install alpamon (includes PAM module by default) sudo yum install alpamon + +# Install without PAM module +sudo yum install alpamon --setopt=install_weak_deps=False +``` + +### PAM Module + +By default, `alpamon` installation includes the `alpamon-pam` package, which provides PAM (Pluggable Authentication Modules) integration for advanced authentication features: +- **pam_alpamon.so**: Verifies Alpacon users during sudo authentication +- **alpacon_approval.so**: Handles sudo command approval requests + +#### Configuration +After installation, configure PAM and sudo to enable the authentication features: + +1. Add to `/etc/pam.d/sudo`: +``` +auth [user_unknown=ignore auth_err=die success=done default=bad] pam_alpamon.so +``` + +2. Add to `/etc/sudo.conf`: +``` +Plugin approval_plugin alpacon_approval.so ``` +**Note**: The Alpamon service must be running with socket at `/var/run/alpamon/auth.sock` for PAM authentication to work. + ### macOS #### Clone the source code diff --git a/cmd/alpamon/command/root.go b/cmd/alpamon/command/root.go index ca6b00f0..dd237e73 100644 --- a/cmd/alpamon/command/root.go +++ b/cmd/alpamon/command/root.go @@ -99,25 +99,32 @@ func runAgent() { metricCollector.Start() } - // Websocket Client + // Websocket Client (Backhaul - commands, sessions) wsClient := runner.NewWebsocketClient(session) go wsClient.RunForever(ctx) + // Control Client (Control - sudo approval) + controlClient := runner.NewControlClient() + go controlClient.RunForever(ctx) + + authManager := runner.GetAuthManager(controlClient) + go authManager.Start(ctx) + for { select { case <-ctx.Done(): log.Info().Msg("Received termination signal. Shutting down...") - gracefulShutdown(metricCollector, wsClient, logRotate, logServer, pidFilePath) + gracefulShutdown(metricCollector, wsClient, controlClient, authManager, logRotate, logServer, pidFilePath) return case <-wsClient.ShutDownChan: log.Info().Msg("Shutdown command received. Shutting down...") cancel() - gracefulShutdown(metricCollector, wsClient, logRotate, logServer, pidFilePath) + gracefulShutdown(metricCollector, wsClient, controlClient, authManager, logRotate, logServer, pidFilePath) return case <-wsClient.RestartChan: log.Info().Msg("Restart command received. Restarting...") cancel() - gracefulShutdown(metricCollector, wsClient, logRotate, logServer, pidFilePath) + gracefulShutdown(metricCollector, wsClient, controlClient, authManager, logRotate, logServer, pidFilePath) restartAgent() return case <-wsClient.CollectorRestartChan: @@ -142,13 +149,19 @@ func restartAgent() { } } -func gracefulShutdown(collector *collector.Collector, wsClient *runner.WebsocketClient, logRotate *lumberjack.Logger, logServer *logger.LogServer, pidPath string) { +func gracefulShutdown(collector *collector.Collector, wsClient *runner.WebsocketClient, controlClient *runner.ControlClient, authManager *runner.AuthManager, logRotate *lumberjack.Logger, logServer *logger.LogServer, pidPath string) { if collector != nil { collector.Stop() } if wsClient != nil { wsClient.Close() } + if controlClient != nil { + controlClient.Close() + } + if authManager != nil { + authManager.Stop() + } if logServer != nil { logServer.Stop() } diff --git a/configs/tmpfile.conf b/configs/tmpfile.conf index 1a9901aa..8adf89fe 100644 --- a/configs/tmpfile.conf +++ b/configs/tmpfile.conf @@ -2,4 +2,5 @@ d /etc/alpamon 0700 root root - - f /etc/alpamon/alpamon.conf 0600 root root - - d /var/lib/alpamon 0750 root root - - f /var/lib/alpamon/alpamon.db 0750 root root - - -d /var/log/alpamon 0750 root root - - \ No newline at end of file +d /var/log/alpamon 0750 root root - - +d /var/run/alpamon 0750 root root - - \ No newline at end of file diff --git a/pkg/runner/auth_manager.go b/pkg/runner/auth_manager.go new file mode 100644 index 00000000..069a186b --- /dev/null +++ b/pkg/runner/auth_manager.go @@ -0,0 +1,553 @@ +package runner + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/cenkalti/backoff" + "github.com/rs/zerolog/log" +) + +type SessionInfo struct { + SessionID string + PID int + PtyClient *PtyClient + Requests map[string]*SudoRequest +} + +type SudoRequest struct { + RequestID string + Connection net.Conn +} + +type SudoApprovalRequest struct { + RequestID string `json:"request_id"` + Type string `json:"type"` + Username string `json:"username"` + Groupname string `json:"groupname"` + PID int `json:"pid"` + PPID int `json:"ppid"` + Command string `json:"command"` + IsAlpconUser bool `json:"is_alpacon_user"` + SessionID string `json:"session_id"` +} + +type SudoApprovalResponse struct { + RequestID string `json:"request_id"` + Type string `json:"type"` + Username string `json:"username"` + Groupname string `json:"groupname"` + PID int `json:"pid"` + PPID int `json:"ppid"` + Command string `json:"command"` + IsAlpconUser bool `json:"is_alpacon_user"` + SessionID string `json:"session_id"` + Approved bool `json:"approved"` + Reason string `json:"reason"` +} + +type MFAResponse struct { + RequestID string `json:"request_id"` + SessionID string `json:"session_id"` + Username string `json:"username"` + Groupname string `json:"groupname"` + PID int `json:"pid"` + PPID int `json:"ppid"` + IsAlpconUser bool `json:"is_alpacon_user"` + Success bool `json:"success"` +} + +type BaseRequest struct { + Type string `json:"type"` +} + +type IsAlpconRequest struct { + Type string `json:"type"` + Username string `json:"username"` + Groupname string `json:"groupname"` + PID int `json:"pid"` + PPID int `json:"ppid"` +} + +type IsAlpconResponse struct { + Type string `json:"type"` + Username string `json:"username"` + Groupname string `json:"groupname"` + PID int `json:"pid"` + PPID int `json:"ppid"` + IsAlpconUser bool `json:"is_alpacon_user"` +} + +type AuthManager struct { + mu sync.RWMutex + ctx context.Context + cancel context.CancelFunc + pidToSessionMap map[int]*SessionInfo + controlClient *ControlClient + listener net.Listener + localSudoRequests map[string]*SudoRequest + completionChannels map[string]chan struct{} +} + +const ( + authRetryInitialInterval = 1 * time.Second + authRetryMaxInterval = 10 * time.Second + authRetryTimeout = 25 * time.Second // Less than PAM 30s timeout +) + +var ( + authManager *AuthManager + authManagerOnce sync.Once +) + +func GetAuthManager(controlClient *ControlClient) *AuthManager { + authManagerOnce.Do(func() { + authManager = &AuthManager{ + pidToSessionMap: make(map[int]*SessionInfo), + localSudoRequests: make(map[string]*SudoRequest), + completionChannels: make(map[string]chan struct{}), + } + }) + + if authManager.controlClient == nil { + authManager.controlClient = controlClient + } + + if authManager.localSudoRequests == nil { + authManager.localSudoRequests = make(map[string]*SudoRequest) + } + + if authManager.completionChannels == nil { + authManager.completionChannels = make(map[string]chan struct{}) + } + + return authManager +} + +func (am *AuthManager) Start(ctx context.Context) { + am.ctx, am.cancel = context.WithCancel(ctx) + + if err := am.startSocketListener(am.ctx); err != nil { + log.Error().Err(err).Msg("Failed to start socket listener") + return + } + + log.Info().Msg("Auth Manager started successfully") + + <-am.ctx.Done() + log.Info().Msg("Auth Manager stopped") +} + +func (am *AuthManager) startSocketListener(ctx context.Context) error { + const socketPath = "/var/run/alpamon/auth.sock" + + // systemd tmpfile will manage the /var/run/alpamon directory + // No need to create directory manually + + if _, err := os.Stat(socketPath); err == nil { + os.Remove(socketPath) + } + + listener, err := net.Listen("unix", socketPath) + if err != nil { + return fmt.Errorf("socket listen error: %w", err) + } + + if err := os.Chmod(socketPath, 0600); err != nil { + return fmt.Errorf("failed to set socket permissions: %w", err) + } + + if err := os.Chown(socketPath, 0, 0); err != nil { + return fmt.Errorf("failed to set socket ownership: %w", err) + } + + log.Info().Msgf("Socket created with permissions 600 (root only)") + + am.listener = listener + log.Info().Msg("Auth socket listener started") + + for { + select { + case <-ctx.Done(): + return nil + default: + unixConn, err := am.listener.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + log.Warn().Err(err).Msg("Socket accept error") + continue + } + + go am.handleSudoRequest(unixConn) + } + } +} + +func (am *AuthManager) sendSudoRequestWithRetry(req SudoApprovalRequest) error { + retryBackoff := backoff.NewExponentialBackOff() + retryBackoff.InitialInterval = authRetryInitialInterval + retryBackoff.MaxInterval = authRetryMaxInterval + retryBackoff.MaxElapsedTime = authRetryTimeout + retryBackoff.RandomizationFactor = 0 + + ctx, cancel := context.WithTimeout(am.ctx, authRetryTimeout) + defer cancel() + + operation := am.createSendOperation(ctx, req, retryBackoff) + return backoff.Retry(operation, backoff.WithContext(retryBackoff, ctx)) +} + +func (am *AuthManager) createSendOperation(ctx context.Context, req SudoApprovalRequest, retryBackoff *backoff.ExponentialBackOff) func() error { + return func() error { + select { + case <-ctx.Done(): + return backoff.Permanent(ctx.Err()) + default: + if am.controlClient == nil || !am.controlClient.IsConnected() { + return fmt.Errorf("control WebSocket client not available") + } + + if err := am.controlClient.WriteJSON(req); err != nil { + nextInterval := retryBackoff.NextBackOff() + log.Warn().Err(err).Msgf("Failed to send sudo request, will retry in %ds", int(nextInterval.Seconds())) + return err + } + + log.Debug().Msg("Sudo request sent successfully") + return nil + } + } +} + +func (am *AuthManager) handleSudoRequest(unixConn net.Conn) { + buf := make([]byte, 1024) + n, err := unixConn.Read(buf) + if err != nil { + log.Warn().Err(err).Msg("Failed to read sudo request") + am.sendIsAlpconResponse(unixConn, "", "", 0, 0, false) + return + } + + var baseReq BaseRequest + if err := json.Unmarshal(buf[:n], &baseReq); err != nil { + log.Warn().Err(err).Msg("Invalid JSON request") + unixConn.Close() + return + } + + if baseReq.Type == "" { + log.Warn().Msg("Missing or invalid type field") + unixConn.Close() + return + } + + switch baseReq.Type { + case "check_user": + var isAlpconReq IsAlpconRequest + if err := json.Unmarshal(buf[:n], &isAlpconReq); err != nil { + log.Warn().Err(err).Msg("Invalid is_alpcon_request") + am.sendIsAlpconResponse(unixConn, "", "", 0, 0, false) + unixConn.Close() + return + } + + am.mu.RLock() + session, exists := am.pidToSessionMap[isAlpconReq.PPID] + am.mu.RUnlock() + + if !exists { + log.Warn().Msgf("No session found for PID %d, username: %s, groupname: %s", isAlpconReq.PPID, isAlpconReq.Username, isAlpconReq.Groupname) + am.sendIsAlpconResponse(unixConn, isAlpconReq.Username, isAlpconReq.Groupname, isAlpconReq.PID, isAlpconReq.PPID, false) + unixConn.Close() + return + } + + log.Debug().Msgf("Session found for PID %d: %s", isAlpconReq.PPID, session.SessionID) + am.sendIsAlpconResponse(unixConn, isAlpconReq.Username, isAlpconReq.Groupname, isAlpconReq.PID, isAlpconReq.PPID, true) + unixConn.Close() + + case "sudo_approval": + am.handleSudoApprovalRequest(buf[:n], unixConn) + + default: + log.Warn().Str("type", baseReq.Type).Msg("Unknown request type") + unixConn.Close() + } +} + +func (am *AuthManager) handleSudoApprovalRequest(data []byte, unixConn net.Conn) { + var sudoApprovalReq SudoApprovalRequest + if err := json.Unmarshal(data, &sudoApprovalReq); err != nil { + log.Warn().Err(err).Msg("Invalid sudo_approval_request") + am.sendSudoApprovalResponse(unixConn, sudoApprovalReq, false, "Invalid sudo_approval_request") + unixConn.Close() + return + } + + // Create completion channel to signal when response is received + completionChan := make(chan struct{}) + + am.mu.Lock() + session, exists := am.pidToSessionMap[sudoApprovalReq.PPID] + if !exists { + // local user: save in localSudoRequests + sudoApprovalReq.IsAlpconUser = false + sudoApprovalReq.SessionID = "" + + am.localSudoRequests[sudoApprovalReq.RequestID] = &SudoRequest{ + RequestID: sudoApprovalReq.RequestID, + Connection: unixConn, + } + am.mu.Unlock() + + log.Debug().Msgf("Local user sudo request: %s for user %s", sudoApprovalReq.RequestID, sudoApprovalReq.Username) + } else { + // Alpacon user: pidToSessionMap + sudoApprovalReq.IsAlpconUser = true + sudoApprovalReq.SessionID = session.SessionID + + session.Requests[sudoApprovalReq.RequestID] = &SudoRequest{ + RequestID: sudoApprovalReq.RequestID, + Connection: unixConn, + } + am.mu.Unlock() + + log.Debug().Msgf("Alpacon user sudo request: %s for session %s", sudoApprovalReq.RequestID, session.SessionID) + } + + // Store completion channel for this request + am.storeCompletionChannel(sudoApprovalReq.RequestID, completionChan) + + // Send Sudo Approval request to the alpacon-server with retry + if err := am.sendSudoRequestWithRetry(sudoApprovalReq); err != nil { + log.Error().Err(err).Msg("Failed to send sudo_approval request after retries") + am.sendSudoApprovalResponse(unixConn, sudoApprovalReq, false, "Communication error") + am.cleanupTimeoutRequest(sudoApprovalReq.RequestID, false, "Communication error") + am.removeCompletionChannel(sudoApprovalReq.RequestID) + unixConn.Close() + return + } + + log.Debug().Msgf("sudo_approval request sent to WebSocket client, waiting for response...") + + // Wait for response, timeout, or context cancellation + select { + case <-completionChan: + // Response received and processed by HandleSudoApprovalResponse + log.Debug().Msgf("sudo_approval response received for request %s", sudoApprovalReq.RequestID) + case <-time.After(30 * time.Second): + log.Warn().Msg("sudo_approval response timeout") + am.cleanupTimeoutRequest(sudoApprovalReq.RequestID, false, "Response timeout") + case <-am.ctx.Done(): + log.Debug().Msg("Context cancelled, cleaning up sudo_approval connection") + am.cleanupTimeoutRequest(sudoApprovalReq.RequestID, false, "Service shutdown") + } + am.removeCompletionChannel(sudoApprovalReq.RequestID) +} + +func (am *AuthManager) sendIsAlpconResponse(conn net.Conn, username, groupname string, pid, ppid int, isAlpconUser bool) { + response := IsAlpconResponse{ + Type: "is_alpacon_response", + Username: username, + Groupname: groupname, + PID: pid, + PPID: ppid, + IsAlpconUser: isAlpconUser, + } + + responseJSON, err := json.Marshal(response) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal is_alpacon_response") + return + } + + _, err = conn.Write(responseJSON) + if err != nil { + log.Error().Err(err).Msg("Failed to send is_alpacon_response") + return + } +} + +// sendSudoApprovalResponse is used when there is something wrong sending the sudo approval request to the alpacon-server +func (am *AuthManager) sendSudoApprovalResponse(conn net.Conn, sudo_approval_req SudoApprovalRequest, approved bool, reason string) { + response := SudoApprovalResponse{ + Type: "sudo_approval_response", + Username: sudo_approval_req.Username, + Groupname: sudo_approval_req.Groupname, + PID: sudo_approval_req.PID, + PPID: sudo_approval_req.PPID, + Command: sudo_approval_req.Command, + IsAlpconUser: sudo_approval_req.IsAlpconUser, + SessionID: sudo_approval_req.SessionID, + RequestID: sudo_approval_req.RequestID, + Approved: approved, + Reason: reason, + } + + responseJSON, err := json.Marshal(response) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal sudo_approval_response") + return + } + + _, err = conn.Write(responseJSON) + if err != nil { + log.Error().Err(err).Msg("Failed to send sudo_approval_response") + return + } +} + +// HandleSudoApprovalResponse is used to handle the sudo_approval response from the alpacon-server +func (am *AuthManager) HandleSudoApprovalResponse(response SudoApprovalResponse) error { + log.Info().Str("request_id", response.RequestID).Bool("approved", response.Approved).Msg("Processing sudo_approval response") + + am.mu.Lock() + var sudoRequest *SudoRequest + + // 1. find in alpacon user requests + for _, session := range am.pidToSessionMap { + if req, exists := session.Requests[response.RequestID]; exists { + delete(session.Requests, response.RequestID) + sudoRequest = req + log.Debug().Msgf("Found Alpacon user request for ID: %s", response.RequestID) + break + } + } + + // 2. find in local user requests + if sudoRequest == nil { + if req, exists := am.localSudoRequests[response.RequestID]; exists { + delete(am.localSudoRequests, response.RequestID) + sudoRequest = req + log.Debug().Msgf("Found local user request for ID: %s", response.RequestID) + } else { + log.Debug().Msgf("Request ID %s not found in localSudoRequests", response.RequestID) + } + } + am.mu.Unlock() + + if sudoRequest == nil { + am.mu.RLock() + log.Debug().Msgf("Current localSudoRequests: %+v", am.localSudoRequests) + for _, session := range am.pidToSessionMap { + log.Debug().Msgf("Session %s requests: %+v", session.SessionID, session.Requests) + } + am.mu.RUnlock() + + return fmt.Errorf("no pending sudo_approval request found for request_id: %s", response.RequestID) + } + + responseJSON, err := json.Marshal(response) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal sudo_approval_response") + return err + } + + _, err = sudoRequest.Connection.Write(responseJSON) + if err != nil { + if strings.Contains(err.Error(), "broken pipe") || strings.Contains(err.Error(), "EPIPE") { + log.Warn().Err(err).Str("request_id", response.RequestID). + Msg("Unix socket broken pipe - client disconnected (expected if timeout)") + } else { + log.Error().Err(err).Msg("Failed to send sudo_approval_response") + } + return err + } + + sudoRequest.Connection.Close() + + // Signal completion to unblock the waiting goroutine + am.signalCompletion(response.RequestID) + + log.Info().Str("request_id", response.RequestID).Bool("approved", response.Approved).Msg("SudoApprovalResponse processed successfully") + return nil +} + +func (am *AuthManager) AddPIDSessionMapping(pid int, session *SessionInfo) { + am.mu.Lock() + am.pidToSessionMap[pid] = session + am.mu.Unlock() +} + +func (am *AuthManager) RemovePIDSessionMapping(pid int) { + am.mu.Lock() + if session, exists := am.pidToSessionMap[pid]; exists { + delete(am.pidToSessionMap, pid) + log.Debug().Msgf("PID mapping removed: %d -> Session: %s", pid, session.SessionID) + } + am.mu.Unlock() +} + +func (am *AuthManager) storeCompletionChannel(requestID string, ch chan struct{}) { + am.mu.Lock() + am.completionChannels[requestID] = ch + am.mu.Unlock() +} + +func (am *AuthManager) removeCompletionChannel(requestID string) { + am.mu.Lock() + delete(am.completionChannels, requestID) + am.mu.Unlock() +} + +func (am *AuthManager) signalCompletion(requestID string) { + am.mu.RLock() + ch, exists := am.completionChannels[requestID] + am.mu.RUnlock() + + if exists { + select { + case ch <- struct{}{}: + default: + // Channel already signaled or closed + } + } +} + +func (am *AuthManager) Stop() { + if am.cancel != nil { + am.cancel() + } + if am.listener != nil { + am.listener.Close() + } +} + +func (am *AuthManager) cleanupTimeoutRequest(requestID string, approved bool, reason string) { + am.mu.Lock() + + // 1. Alpacon user + for _, session := range am.pidToSessionMap { + if req, exists := session.Requests[requestID]; exists { + delete(session.Requests, requestID) + am.mu.Unlock() + if req.Connection != nil { + am.sendSudoApprovalResponse(req.Connection, SudoApprovalRequest{RequestID: requestID}, approved, reason) + req.Connection.Close() + } + return + } + } + + // 2. Local user + if req, exists := am.localSudoRequests[requestID]; exists { + delete(am.localSudoRequests, requestID) + am.mu.Unlock() + if req.Connection != nil { + am.sendSudoApprovalResponse(req.Connection, SudoApprovalRequest{RequestID: requestID}, approved, reason) + req.Connection.Close() + } + return + } + + am.mu.Unlock() + log.Warn().Msgf("Timeout request not found: %s", requestID) +} diff --git a/pkg/runner/command.go b/pkg/runner/command.go index ec0254f7..555bc84f 100644 --- a/pkg/runner/command.go +++ b/pkg/runner/command.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -305,6 +306,10 @@ func (cr *CommandRunner) handleInternalCmd() (int, string) { shutdown: shutdown system ` return 0, helpMessage + + case "sudo_approval_response": + return cr.handleSudoApprovalResponse() + default: return 1, fmt.Sprintf("Invalid command %s", args[0]) } @@ -1635,6 +1640,33 @@ func statFileTransfer(code int, transferType transferType, message string, data scheduler.Rqueue.Post(statURL, payload, 10, time.Time{}) } +func (cr *CommandRunner) handleSudoApprovalResponse() (int, string) { + var sudoApprovalResponse SudoApprovalResponse + if cr.command.Data != "" { + err := json.Unmarshal([]byte(cr.command.Data), &sudoApprovalResponse) + if err != nil { + log.Error().Err(err).Msg("Failed to parse sudo_approval_response data") + return 1, "Invalid sudo_approval_response data format" + } + } else { + return 1, "No sudo_approval_response data provided" + } + + if authManager == nil { + log.Error().Msg("AuthManager not available") + return 1, "AuthManager not available" + } + + // SudoApprovalResponse + err := authManager.HandleSudoApprovalResponse(sudoApprovalResponse) + if err != nil { + log.Error().Err(err).Msg("Failed to handle sudo_approval_response") + return 1, fmt.Sprintf("Failed to handle sudo_approval_response: %v", err) + } + + return 0, "Sudo approval response processed successfully" +} + // validateFirewallData performs enhanced validation for firewall data func (cr *CommandRunner) validateFirewallData(data firewallData) error { // Basic validation using struct tags diff --git a/pkg/runner/control_client.go b/pkg/runner/control_client.go new file mode 100644 index 00000000..79bcd0ac --- /dev/null +++ b/pkg/runner/control_client.go @@ -0,0 +1,215 @@ +package runner + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/alpacax/alpamon/pkg/config" + "github.com/alpacax/alpamon/pkg/utils" + "github.com/cenkalti/backoff" + "github.com/gorilla/websocket" + "github.com/rs/zerolog/log" +) + +const ( + controlWSPath = "/ws/servers/control/" + controlMinConnectInterval = 5 * time.Second + controlMaxConnectInterval = 60 * time.Second + controlReadTimeout = 35 * time.Minute +) + +// ControlClient handles WebSocket connection for control messages (sudo_approval, etc.) +type ControlClient struct { + Conn *websocket.Conn + requestHeader http.Header + mu sync.Mutex + connected bool +} + +// NewControlClient creates a new ControlClient +func NewControlClient() *ControlClient { + headers := http.Header{ + "Authorization": {fmt.Sprintf(`id="%s", key="%s"`, config.GlobalSettings.ID, config.GlobalSettings.Key)}, + "Origin": {config.GlobalSettings.ServerURL}, + "User-Agent": {utils.GetUserAgent("alpamon")}, + } + + return &ControlClient{ + requestHeader: headers, + } +} + +// GetWSPath returns the WebSocket URL for control endpoint +func (cc *ControlClient) GetWSPath() string { + // Build control WebSocket path from server URL + serverURL := config.GlobalSettings.ServerURL + wsURL := serverURL + if len(wsURL) > 0 { + // Replace http with ws + if wsURL[0:5] == "https" { + wsURL = "wss" + wsURL[5:] + } else if wsURL[0:4] == "http" { + wsURL = "ws" + wsURL[4:] + } + } + return wsURL + controlWSPath +} + +// RunForever maintains the control WebSocket connection and handles messages +func (cc *ControlClient) RunForever(ctx context.Context) { + cc.Connect() + + for { + select { + case <-ctx.Done(): + cc.Close() + return + default: + if cc.Conn == nil { + cc.Connect() + continue + } + + err := cc.Conn.SetReadDeadline(time.Now().Add(controlReadTimeout)) + if err != nil { + cc.CloseAndReconnect(ctx) + continue + } + + _, message, err := cc.Conn.ReadMessage() + if err != nil { + cc.CloseAndReconnect(ctx) + continue + } + + cc.HandleMessage(message) + } + } +} + +// Connect establishes WebSocket connection to control endpoint +func (cc *ControlClient) Connect() { + wsPath := cc.GetWSPath() + log.Info().Msgf("Connecting to control websocket at %s...", wsPath) + + wsBackoff := backoff.NewExponentialBackOff() + wsBackoff.InitialInterval = controlMinConnectInterval + wsBackoff.MaxInterval = controlMaxConnectInterval + wsBackoff.MaxElapsedTime = 0 // Infinite retry + wsBackoff.RandomizationFactor = 0 + + operation := func() error { + dialer := websocket.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: !config.GlobalSettings.SSLVerify, + }, + } + conn, _, err := dialer.Dial(wsPath, cc.requestHeader) + if err != nil { + nextInterval := wsBackoff.NextBackOff() + log.Debug().Err(err).Msgf("Failed to connect to control endpoint %s, will try again in %ds.", wsPath, int(nextInterval.Seconds())) + return err + } + + cc.mu.Lock() + cc.Conn = conn + cc.connected = true + cc.mu.Unlock() + + log.Info().Msg("Control WebSocket connection established.") + return nil + } + + _ = backoff.Retry(operation, wsBackoff) +} + +// CloseAndReconnect closes current connection and reconnects +func (cc *ControlClient) CloseAndReconnect(ctx context.Context) { + if ctx.Err() != nil { + return + } + cc.Close() + cc.Connect() +} + +// Close cleanly closes the WebSocket connection +func (cc *ControlClient) Close() { + cc.mu.Lock() + defer cc.mu.Unlock() + + if cc.Conn == nil { + return + } + + cc.connected = false + + err := cc.Conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), + time.Now().Add(5*time.Second), + ) + if err != nil { + log.Debug().Err(err).Msg("Failed to write close message to control websocket.") + } + + _ = cc.Conn.Close() + cc.Conn = nil +} + +// WriteJSON sends JSON data through the WebSocket connection +func (cc *ControlClient) WriteJSON(data interface{}) error { + cc.mu.Lock() + defer cc.mu.Unlock() + + if cc.Conn == nil { + return fmt.Errorf("control WebSocket not connected") + } + + err := cc.Conn.WriteJSON(data) + if err != nil { + log.Debug().Err(err).Msg("Failed to write JSON to control websocket.") + return err + } + return nil +} + +// IsConnected returns whether the client is connected +func (cc *ControlClient) IsConnected() bool { + cc.mu.Lock() + defer cc.mu.Unlock() + return cc.connected && cc.Conn != nil +} + +// HandleMessage processes incoming control messages +func (cc *ControlClient) HandleMessage(message []byte) { + if len(message) == 0 { + return + } + + var response SudoApprovalResponse + err := json.Unmarshal(message, &response) + if err != nil { + log.Debug().Err(err).Msg("Failed to unmarshal control message") + return + } + + switch response.Type { + case "sudo_approval_response": + log.Debug().Msgf("Received sudo_approval_response: %+v", response) + if authManager != nil { + err := authManager.HandleSudoApprovalResponse(response) + if err != nil { + log.Error().Err(err).Msg("Failed to handle sudo approval response") + } + } else { + log.Error().Msg("AuthManager not available") + } + default: + log.Debug().Str("type", response.Type).Msg("Unknown control message type") + } +} diff --git a/pkg/runner/pty.go b/pkg/runner/pty.go index 3341fd75..db5484cb 100644 --- a/pkg/runner/pty.go +++ b/pkg/runner/pty.go @@ -101,6 +101,18 @@ func (pc *PtyClient) initializePtySession() error { } terminals[pc.sessionID] = pc + + pid := pc.cmd.Process.Pid + sessionInfo := &SessionInfo{ + SessionID: pc.sessionID, + PID: pid, + PtyClient: pc, + Requests: make(map[string]*SudoRequest), + } + + authManager.AddPIDSessionMapping(pid, sessionInfo) + log.Debug().Msgf("PID mapping added: %d -> Session: %s", pid, pc.sessionID) + return nil } @@ -257,6 +269,11 @@ func (pc *PtyClient) resize(rows, cols uint16) error { // close terminates the PTY session and cleans up resources. // It ensures that the PTY, command, and WebSocket connection are properly closed. func (pc *PtyClient) close() { + // Remove PID-to-session mapping before cleaning up + if pc.cmd != nil && pc.cmd.Process != nil { + authManager.RemovePIDSessionMapping(pc.cmd.Process.Pid) + } + if pc.ptmx != nil { _ = pc.ptmx.Close() }