Files
pulse/internal/api/agent_install_command_shared.go
T
2026-08-11 16:37:49 +01:00

362 lines
11 KiB
Go

package api
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"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)
}
// Install tokens are minted without an expiry because the agent reports
// with them for the life of the install. Stamp the mint time so the
// one-shot Proxmox bootstrap grant they carry can expire on its own clock
// (proxmoxInstallBootstrapGrantTTL) instead of staying live forever.
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
record.Metadata[agentInstallTokenIssuedAtKey] = record.CreatedAt.UTC().Format(time.RFC3339)
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
Insecure 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 buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string {
baseURL := normalizeAgentInstallBaseURL(opts.BaseURL)
installScriptURL := baseURL + "/install.sh"
curlFlags := "-fsSL"
if opts.Insecure {
curlFlags = "-kfsSL"
}
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 %s %s | bash -s -- \
--url %s \
--enable-proxmox`,
tokenSetup, curlFlags, posixShellQuote(installScriptURL), posixShellQuote(baseURL))
command += tokenArg
if opts.Insecure || 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 containerRuntimeAgentScopes(enableHost bool) []string {
scopes := []string{config.ScopeDockerReport}
if enableHost {
scopes = append(scopes,
config.ScopeAgentReport,
config.ScopeAgentConfigRead,
config.ScopeAgentManage,
)
}
return scopes
}
func containerRuntimeAgentHostFlag(enableHost bool) string {
if enableHost {
return "--enable-host"
}
return "--enable-host=false"
}
func buildContainerRuntimeAgentInstallCommand(baseURL string, token string, enableHost bool) string {
normalizedBaseURL := normalizeAgentInstallBaseURL(baseURL)
installScriptURL := normalizedBaseURL + "/install.sh"
command := fmt.Sprintf(`curl -fsSL %s | bash -s -- \
--url %s \
--enable-docker \
%s \
--interval 30s`,
posixShellQuote(installScriptURL), posixShellQuote(normalizedBaseURL), containerRuntimeAgentHostFlag(enableHost))
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, hostedMode bool) string {
return resolveConfiguredPublicBaseURL(req, cfg, hostedMode)
}
func writeConfigAgentInstallBaseURLUnavailable(w http.ResponseWriter) {
http.Error(w, "A valid external Pulse URL is required", http.StatusServiceUnavailable)
}