Pulse/internal/api/agent_install_command_shared.go
rcourtman d56659a402 Mint host agent install tokens server-side with the exec scope the checkbox asks for
The Add Pulse Agent flow composed a fixed scope list in the frontend,
so ticking Enable Pulse command execution added --enable-commands to
the install command while the token it shipped with never carried
agent:exec, and the command channel rejected every registration
(issues #1586, #1564, confirmed by a reporter's server log). Even with
the scope, the generic token had no binding metadata, so the first-use
binding gate would have refused it next.

The install token is now minted through POST /api/agent-install-command
with type 'host'. The server decides the scopes from enableCommands at
mint time, stamps the install_type/issued_via metadata that makes the
token eligible for first-use command-channel binding
(canBindProxmoxAgentInstallExecToken renamed canBindAgentInstallExecToken
and extended to the host install type), and returns the sanitized token
record. The frontend regenerates the token when the checkbox toggles,
since scopes cannot be upgraded on an existing token, and revokes the
superseded token so toggling does not accumulate orphans.

Contract-Neutral: install-token mint bugfix (#1586, #1564): checkbox-promised exec scope now real; agent-lifecycle contract delta deferred because the contract docs carry another agent's uncommitted WIP on the shared tree
2026-07-18 11:54:09 +01:00

378 lines
12 KiB
Go

package api
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
const (
proxmoxInstallTypePVE = "pve"
proxmoxInstallTypePBS = "pbs"
// agentInstallTypeHost marks install tokens minted for the generic unified
// host agent flow (Settings > Infrastructure > Add Pulse Agent), as opposed
// to the Proxmox-specific pve/pbs installer.
agentInstallTypeHost = "host"
)
var (
errAgentInstallTokenGeneration = errors.New("agent install token generation failed")
errAgentInstallTokenRecord = errors.New("agent install token record failed")
errAgentInstallTokenPersist = errors.New("agent install token persistence failed")
)
func normalizeProxmoxInstallType(raw string) (string, error) {
installType := strings.ToLower(strings.TrimSpace(raw))
if installType != proxmoxInstallTypePVE && installType != proxmoxInstallTypePBS {
return "", fmt.Errorf("Type must be 'pve' or 'pbs'")
}
return installType, nil
}
func proxmoxAgentInstallScopes() []string {
return []string{
config.ScopeAgentReport,
config.ScopeAgentConfigRead,
config.ScopeAgentManage,
config.ScopeAgentExec,
}
}
// hostAgentInstallScopes returns the scopes for a generic unified host agent
// install token. The exec scope is included only when the operator asked for
// command execution, because the token is minted before the agent enrols and
// scopes cannot be upgraded on an existing token.
func hostAgentInstallScopes(enableCommands bool) []string {
scopes := []string{
config.ScopeAgentReport,
config.ScopeAgentConfigRead,
config.ScopeAgentManage,
config.ScopeDockerReport,
config.ScopeKubernetesReport,
}
if enableCommands {
scopes = append(scopes, config.ScopeAgentExec)
}
return scopes
}
type issueAgentInstallTokenOptions struct {
TokenName string
OrgID string
OwnerUserID string
Metadata map[string]string
// Scopes overrides the default Proxmox install scope set when non-empty
// (used by the generic host agent flow, which honours the operator's
// command-execution choice instead of always granting exec).
Scopes []string
}
func issueAndPersistAgentInstallToken(cfg *config.Config, persistence *config.ConfigPersistence, opts issueAgentInstallTokenOptions) (string, *config.APITokenRecord, error) {
if cfg == nil {
return "", nil, fmt.Errorf("config is required")
}
rawToken, err := internalauth.GenerateAPIToken()
if err != nil {
return "", nil, fmt.Errorf("%w: %w", errAgentInstallTokenGeneration, err)
}
scopes := opts.Scopes
if len(scopes) == 0 {
scopes = proxmoxAgentInstallScopes()
}
record, err := config.NewAPITokenRecord(rawToken, opts.TokenName, scopes)
if err != nil {
return "", nil, fmt.Errorf("%w: %w", errAgentInstallTokenRecord, err)
}
record.OrgID = strings.TrimSpace(opts.OrgID)
setAPITokenOwnerUserID(record, opts.OwnerUserID)
if err := mergeAPITokenMetadata(record, opts.Metadata); err != nil {
return "", nil, fmt.Errorf("%w: %w", errAgentInstallTokenRecord, err)
}
config.Mu.Lock()
defer config.Mu.Unlock()
cfg.APITokens = append(cfg.APITokens, *record)
cfg.SortAPITokens()
if persistence != nil {
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
cfg.APITokens = cfg.APITokens[:len(cfg.APITokens)-1]
return "", nil, fmt.Errorf("%w: %w", errAgentInstallTokenPersist, err)
}
}
return rawToken, record, nil
}
type agentInstallCommandOptions struct {
BaseURL string
Token string
InstallType string
IncludeInstallType bool
EnableCommands bool
}
type setupScriptInstallArtifact struct {
Type string `json:"type"`
Host string `json:"host"`
URL string `json:"url"`
DownloadURL string `json:"downloadURL"`
ScriptFileName string `json:"scriptFileName"`
Command string `json:"command"`
CommandWithEnv string `json:"commandWithEnv"`
CommandWithoutEnv string `json:"commandWithoutEnv"`
Expires int64 `json:"expires"`
SetupToken string `json:"setupToken"`
TokenHint string `json:"tokenHint"`
}
func normalizeAgentInstallBaseURL(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
func posixShellQuote(value string) string {
escaped := strings.ReplaceAll(value, "'", `'"'"'`)
return "'" + escaped + "'"
}
func installBaseURLRequiresInsecure(raw string) bool {
baseURL := strings.ToLower(strings.TrimSpace(raw))
return strings.HasPrefix(baseURL, "http://")
}
func authConfiguredForAgentLifecycle(cfg *config.Config) bool {
if cfg == nil {
return false
}
return (strings.TrimSpace(cfg.AuthUser) != "" && strings.TrimSpace(cfg.AuthPass) != "") ||
cfg.HasAPITokens() ||
strings.TrimSpace(cfg.ProxyAuthSecret) != "" ||
hasEnabledSSOProvidersForAuth(cfg)
}
func withPrivilegeEscalation(command string) string {
const installPipe = "| bash -s --"
idx := strings.Index(command, installPipe)
if idx == -1 {
return command
}
args := command[idx+len(installPipe):]
return command[:idx] +
`| { if [ "$(id -u)" -eq 0 ]; then bash -s --` + args +
`; elif command -v sudo >/dev/null 2>&1; then sudo bash -s --` + args +
`; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`
}
func requestTargetsLocalFrontend(host string, frontendPort int) bool {
if frontendPort <= 0 {
return false
}
parsedHost, parsedPort, err := net.SplitHostPort(strings.TrimSpace(host))
if err != nil || parsedPort != strconv.Itoa(frontendPort) {
return false
}
switch strings.Trim(parsedHost, "[]") {
case "127.0.0.1", "localhost", "::1":
return true
default:
return false
}
}
func resolveLoopbackAwarePublicBaseURL(req *http.Request, cfg *config.Config) string {
if req != nil && cfg != nil && requestTargetsLocalFrontend(req.Host, cfg.FrontendPort) {
if publicURL := strings.TrimSpace(cfg.PublicURL); publicURL != "" {
if parsedURL, err := url.Parse(publicURL); err == nil && parsedURL.Scheme != "" && parsedURL.Host != "" {
return strings.TrimRight(parsedURL.String(), "/")
}
}
}
host := ""
if req != nil {
host = strings.TrimSpace(req.Host)
}
if host == "" {
if cfg != nil && cfg.FrontendPort > 0 {
host = fmt.Sprintf("localhost:%d", cfg.FrontendPort)
} else {
host = "localhost:7655"
}
}
scheme := "http"
if req != nil && (req.TLS != nil || strings.EqualFold(req.Header.Get("X-Forwarded-Proto"), "https")) {
scheme = "https"
}
return fmt.Sprintf("%s://%s", scheme, host)
}
func buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string {
baseURL := normalizeAgentInstallBaseURL(opts.BaseURL)
installScriptURL := baseURL + "/install.sh"
token := strings.TrimSpace(opts.Token)
tokenSetup := ""
tokenArg := ""
tokenCleanup := ""
if token != "" {
tokenSetup = fmt.Sprintf(`token_file=$(mktemp) && chmod 600 "$token_file" && printf %%s %s > "$token_file" && `, posixShellQuote(token))
tokenArg = ` \
--token-file "$token_file"`
tokenCleanup = `; rc=$?; rm -f "$token_file"; exit $rc`
}
command := fmt.Sprintf(`%scurl -fsSL %s | bash -s -- \
--url %s \
--enable-proxmox`,
tokenSetup, posixShellQuote(installScriptURL), posixShellQuote(baseURL))
command += tokenArg
if installBaseURLRequiresInsecure(baseURL) {
command += ` \
--insecure`
}
if opts.IncludeInstallType {
command += fmt.Sprintf(` \
--proxmox-type %s`, posixShellQuote(opts.InstallType))
}
if opts.EnableCommands {
command += ` \
--enable-commands`
}
return withPrivilegeEscalation(command) + tokenCleanup
}
func buildContainerRuntimeAgentInstallCommand(baseURL string, token string) string {
normalizedBaseURL := normalizeAgentInstallBaseURL(baseURL)
installScriptURL := normalizedBaseURL + "/install.sh"
command := fmt.Sprintf(`curl -fsSL %s | bash -s -- \
--url %s \
--enable-docker \
--enable-host=false \
--interval 30s`,
posixShellQuote(installScriptURL), posixShellQuote(normalizedBaseURL))
if trimmedToken := strings.TrimSpace(token); trimmedToken != "" {
command += fmt.Sprintf(` \
--token %s`, posixShellQuote(trimmedToken))
}
if installBaseURLRequiresInsecure(normalizedBaseURL) {
command += ` \
--insecure`
}
return withPrivilegeEscalation(command)
}
func buildSetupScriptCommand(scriptURL string, token string) string {
curlCommand := "curl -fsSL " + posixShellQuote(strings.TrimSpace(scriptURL)) + " | "
bashCommand := "bash"
sudoCommand := "sudo bash"
if trimmedToken := strings.TrimSpace(token); trimmedToken != "" {
envPrefix := "PULSE_SETUP_TOKEN=" + posixShellQuote(trimmedToken) + " "
bashCommand = envPrefix + bashCommand
sudoCommand = "sudo env " + envPrefix + "bash"
}
return curlCommand +
`{ if [ "$(id -u)" -eq 0 ]; then ` + bashCommand +
`; elif command -v sudo >/dev/null 2>&1; then ` + sudoCommand +
`; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`
}
func buildSetupScriptTokenHint(token string) string {
trimmed := strings.TrimSpace(token)
if len(trimmed) <= 6 {
return trimmed
}
return fmt.Sprintf("%s…%s", trimmed[:3], trimmed[len(trimmed)-3:])
}
func buildSetupScriptURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool) string {
query := url.Values{}
query.Set("type", strings.TrimSpace(installType))
if trimmedHost := strings.TrimSpace(host); trimmedHost != "" {
query.Set("host", trimmedHost)
}
if trimmedPulseURL := strings.TrimSpace(pulseURL); trimmedPulseURL != "" {
query.Set("pulse_url", trimmedPulseURL)
}
if backupPerms && strings.TrimSpace(installType) == "pve" {
query.Set("backup_perms", "true")
}
return normalizeAgentInstallBaseURL(baseURL) + "/api/setup-script?" + query.Encode()
}
func buildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string) string {
downloadURL := buildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
trimmedToken := strings.TrimSpace(setupToken)
if trimmedToken == "" {
return downloadURL
}
parsed, err := url.Parse(downloadURL)
if err != nil {
return downloadURL
}
query := parsed.Query()
query.Set("setup_token", trimmedToken)
parsed.RawQuery = query.Encode()
return parsed.String()
}
func buildSetupScriptFileName(installType string) string {
return fmt.Sprintf("pulse-setup-%s.sh", strings.TrimSpace(installType))
}
func buildSetupScriptInstallArtifact(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string, expiresAt int64) setupScriptInstallArtifact {
scriptURL := buildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
commandWithEnv := buildSetupScriptCommand(scriptURL, setupToken)
return setupScriptInstallArtifact{
Type: strings.TrimSpace(installType),
Host: strings.TrimSpace(host),
URL: scriptURL,
DownloadURL: buildSetupScriptDownloadURL(baseURL, installType, host, pulseURL, backupPerms, setupToken),
ScriptFileName: buildSetupScriptFileName(installType),
Command: commandWithEnv,
CommandWithEnv: commandWithEnv,
CommandWithoutEnv: buildSetupScriptCommand(scriptURL, ""),
Expires: expiresAt,
SetupToken: strings.TrimSpace(setupToken),
TokenHint: buildSetupScriptTokenHint(setupToken),
}
}
func resolveConfigAgentInstallBaseURL(req *http.Request, cfg *config.Config) string {
if cfg != nil {
if agentConnectURL := strings.TrimSpace(cfg.AgentConnectURL); agentConnectURL != "" {
return normalizeAgentInstallBaseURL(agentConnectURL)
}
}
return resolveLoopbackAwarePublicBaseURL(req, cfg)
}