diff --git a/cmd/pulse/agent_binaries.go b/cmd/pulse/agent_binaries.go new file mode 100644 index 000000000..7b25c2182 --- /dev/null +++ b/cmd/pulse/agent_binaries.go @@ -0,0 +1,318 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +type hostAgentBinary struct { + platform string + arch string + filenames []string +} + +var requiredHostAgentBinaries = []hostAgentBinary{ + {platform: "linux", arch: "amd64", filenames: []string{"pulse-host-agent-linux-amd64"}}, + {platform: "linux", arch: "arm64", filenames: []string{"pulse-host-agent-linux-arm64"}}, + {platform: "linux", arch: "armv7", filenames: []string{"pulse-host-agent-linux-armv7"}}, + {platform: "darwin", arch: "amd64", filenames: []string{"pulse-host-agent-darwin-amd64"}}, + {platform: "darwin", arch: "arm64", filenames: []string{"pulse-host-agent-darwin-arm64"}}, + { + platform: "windows", + arch: "amd64", + filenames: []string{"pulse-host-agent-windows-amd64", "pulse-host-agent-windows-amd64.exe"}, + }, + { + platform: "windows", + arch: "arm64", + filenames: []string{"pulse-host-agent-windows-arm64", "pulse-host-agent-windows-arm64.exe"}, + }, + { + platform: "windows", + arch: "386", + filenames: []string{"pulse-host-agent-windows-386", "pulse-host-agent-windows-386.exe"}, + }, +} + +func validateAgentBinaries() { + binDirs := hostAgentSearchPaths() + missing := findMissingHostAgentBinaries(binDirs) + if len(missing) == 0 { + log.Info().Msg("All host agent binaries available for download") + return + } + + missingPlatforms := make([]string, 0, len(missing)) + for key := range missing { + missingPlatforms = append(missingPlatforms, key) + } + sort.Strings(missingPlatforms) + + log.Warn(). + Strs("missing_platforms", missingPlatforms). + Msg("Host agent binaries missing - attempting to download bundle from GitHub release") + + if err := downloadAndInstallHostAgentBinaries(binDirs[0]); err != nil { + log.Error(). + Err(err). + Str("target_dir", binDirs[0]). + Strs("missing_platforms", missingPlatforms). + Msg("Failed to automatically install host agent binaries; install script downloads will fail") + return + } + + remaining := findMissingHostAgentBinaries(binDirs) + if len(remaining) == 0 { + log.Info().Msg("Host agent binaries restored from GitHub release bundle") + return + } + + stillMissing := make([]string, 0, len(remaining)) + for key := range remaining { + stillMissing = append(stillMissing, key) + } + sort.Strings(stillMissing) + log.Warn(). + Strs("missing_platforms", stillMissing). + Msg("Host agent binaries still missing after automatic restoration attempt") +} + +func hostAgentSearchPaths() []string { + primary := strings.TrimSpace(os.Getenv("PULSE_BIN_DIR")) + if primary == "" { + primary = "/opt/pulse/bin" + } + + dirs := []string{primary, "./bin", "."} + seen := make(map[string]struct{}, len(dirs)) + result := make([]string, 0, len(dirs)) + for _, dir := range dirs { + clean := filepath.Clean(dir) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + result = append(result, clean) + } + return result +} + +func findMissingHostAgentBinaries(binDirs []string) map[string]hostAgentBinary { + missing := make(map[string]hostAgentBinary) + for _, binary := range requiredHostAgentBinaries { + if !hostAgentBinaryExists(binDirs, binary.filenames) { + key := fmt.Sprintf("%s-%s", binary.platform, binary.arch) + missing[key] = binary + } + } + return missing +} + +func hostAgentBinaryExists(binDirs, filenames []string) bool { + for _, dir := range binDirs { + for _, name := range filenames { + path := filepath.Join(dir, name) + if info, err := os.Stat(path); err == nil && !info.IsDir() { + return true + } + } + } + return false +} + +func downloadAndInstallHostAgentBinaries(targetDir string) error { + version := strings.TrimSpace(Version) + if version == "" || strings.EqualFold(version, "dev") { + return fmt.Errorf("cannot download host agent bundle for non-release version %q", version) + } + + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return fmt.Errorf("failed to ensure bin directory %s: %w", targetDir, err) + } + + url := fmt.Sprintf("https://github.com/rcourtman/Pulse/releases/download/%[1]s/pulse-%[1]s.tar.gz", version) + tempFile, err := os.CreateTemp("", "pulse-host-agent-*.tar.gz") + if err != nil { + return fmt.Errorf("failed to create temporary archive file: %w", err) + } + defer os.Remove(tempFile.Name()) + + client := &http.Client{Timeout: 2 * time.Minute} + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("failed to download host agent bundle from %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, strings.TrimSpace(string(body))) + } + + if _, err := io.Copy(tempFile, resp.Body); err != nil { + return fmt.Errorf("failed to save host agent bundle: %w", err) + } + + if err := tempFile.Close(); err != nil { + return fmt.Errorf("failed to close temporary bundle file: %w", err) + } + + if err := extractHostAgentBinaries(tempFile.Name(), targetDir); err != nil { + return err + } + + return nil +} + +func extractHostAgentBinaries(archivePath, targetDir string) error { + file, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("failed to open host agent bundle: %w", err) + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tr := tar.NewReader(gzReader) + type pendingLink struct { + path string + target string + } + var symlinks []pendingLink + + for { + header, err := tr.Next() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return fmt.Errorf("failed to read host agent bundle: %w", err) + } + + if header == nil { + continue + } + + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA && header.Typeflag != tar.TypeSymlink { + continue + } + + if !strings.HasPrefix(header.Name, "bin/") { + continue + } + + base := path.Base(header.Name) + if !strings.HasPrefix(base, "pulse-host-agent-") { + continue + } + + destPath := filepath.Join(targetDir, base) + + switch header.Typeflag { + case tar.TypeReg, tar.TypeRegA: + if err := writeHostAgentFile(destPath, tr, header.FileInfo().Mode()); err != nil { + return err + } + case tar.TypeSymlink: + symlinks = append(symlinks, pendingLink{ + path: destPath, + target: header.Linkname, + }) + } + } + + for _, link := range symlinks { + if err := os.Remove(link.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to replace existing symlink %s: %w", link.path, err) + } + if err := os.Symlink(link.target, link.path); err != nil { + // Fallback: copy the referenced file if symlinks are not permitted + source := filepath.Join(targetDir, link.target) + if err := copyHostAgentFile(source, link.path); err != nil { + return fmt.Errorf("failed to create symlink %s -> %s: %w", link.path, link.target, err) + } + } + } + + return nil +} + +func writeHostAgentFile(destination string, reader io.Reader, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", destination, err) + } + + tmpFile, err := os.CreateTemp(filepath.Dir(destination), "pulse-host-agent-*") + if err != nil { + return fmt.Errorf("failed to create temporary file for %s: %w", destination, err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := io.Copy(tmpFile, reader); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to extract %s: %w", destination, err) + } + + if err := tmpFile.Chmod(normalizeExecutableMode(mode)); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to set permissions on %s: %w", destination, err) + } + + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to finalize %s: %w", destination, err) + } + + if err := os.Rename(tmpFile.Name(), destination); err != nil { + return fmt.Errorf("failed to install %s: %w", destination, err) + } + + return nil +} + +func copyHostAgentFile(source, destination string) error { + src, err := os.Open(source) + if err != nil { + return fmt.Errorf("failed to open %s for fallback copy: %w", source, err) + } + defer src.Close() + + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return fmt.Errorf("failed to prepare directory for %s: %w", destination, err) + } + + dst, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("failed to create fallback copy %s: %w", destination, err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("failed to copy %s to %s: %w", source, destination, err) + } + + return nil +} + +func normalizeExecutableMode(mode os.FileMode) os.FileMode { + perms := mode.Perm() + if perms&0o111 == 0 { + perms |= 0o755 + } + return (mode &^ os.ModePerm) | perms +} diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go index 81f3333d7..fb27b9a0b 100644 --- a/cmd/pulse/main.go +++ b/cmd/pulse/main.go @@ -353,46 +353,3 @@ func performAutoImport() error { log.Info().Msg("Configuration auto-imported successfully") return nil } - -// validateAgentBinaries checks if agent binaries are available for download -// and logs warnings if any are missing -func validateAgentBinaries() { - binDirs := []string{"/opt/pulse/bin", "./bin", "."} - platforms := []struct { - name string - file string - }{ - {"linux-amd64", "pulse-host-agent-linux-amd64"}, - {"linux-arm64", "pulse-host-agent-linux-arm64"}, - {"linux-armv7", "pulse-host-agent-linux-armv7"}, - {"darwin-amd64", "pulse-host-agent-darwin-amd64"}, - {"darwin-arm64", "pulse-host-agent-darwin-arm64"}, - {"windows-amd64", "pulse-host-agent-windows-amd64"}, - } - - missing := []string{} - searchedPaths := []string{} - - for _, platform := range platforms { - found := false - for _, dir := range binDirs { - path := filepath.Join(dir, platform.file) - searchedPaths = append(searchedPaths, path) - if _, err := os.Stat(path); err == nil { - found = true - break - } - } - if !found { - missing = append(missing, platform.name) - } - } - - if len(missing) > 0 { - log.Warn(). - Strs("missing_platforms", missing). - Msg("Host agent binaries missing - install script downloads will fail. Rebuild Docker image or run build-release.sh to generate all platform binaries.") - } else { - log.Info().Msg("All host agent binaries available for download") - } -} diff --git a/docs/DOCKER_MONITORING.md b/docs/DOCKER_MONITORING.md index b7eb4f372..b71b4e8c2 100644 --- a/docs/DOCKER_MONITORING.md +++ b/docs/DOCKER_MONITORING.md @@ -67,6 +67,18 @@ curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install rm -f /tmp/pulse-install-docker-agent.sh ``` +### Removing the agent + +Re-run the installer with `--uninstall` to stop the service, delete the systemd unit, and keep the logs for later review: + +```bash +curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \ + sudo bash /tmp/pulse-install-docker-agent.sh --uninstall && \ + rm -f /tmp/pulse-install-docker-agent.sh +``` + +Add `--purge` when you also want the installer to remove `/var/log/pulse-docker-agent`, the env/config files under `/etc/pulse`, and the `pulse-docker` service account. Purging cleans up `/etc/passwd` so container processes that run as UID 999 stop showing up as the `pulse-docker` user in `ps` output. + ### Quick install for Podman (system service) Use the multi-runtime installer when you want the agent to run against Podman as a systemd service. The script takes care of enabling `podman.socket`, creating a dedicated service account, and wiring the correct runtime socket automatically: diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7f5cec6f2..7282d305b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -23,6 +23,8 @@ Pulse protects the initial Quick Security Setup screen with a one-time bootstrap | Docker container | `/data/.bootstrap_token` inside the container or the mounted host volume | | Helm / Kubernetes | The persistent volume mounted at `/data` | +> **Tip for LXC deployments:** If your Pulse container runs inside an LXC with a custom hostname (common with docker-compose/systemd units), export `PULSE_LXC_CTID=` in the service environment. The setup wizard falls back to this variable when it cannot auto-detect the numeric CTID, so the on-screen instructions show `pct exec ` with the correct value instead of a placeholder. + **For Proxmox Quick Install (LXC):** The installer creates an LXC container, so the token is inside the container, not on the PVE host. Use one of these commands from your Proxmox host: ```bash diff --git a/scripts/install-docker-agent.sh b/scripts/install-docker-agent.sh index 8baae9f47..e4dd21eaa 100755 --- a/scripts/install-docker-agent.sh +++ b/scripts/install-docker-agent.sh @@ -365,6 +365,11 @@ ensure_service_user() { if [[ -n "$existing_home" ]]; then SERVICE_HOME="$existing_home" fi + if ! service_user_managed_by_installer; then + if [[ "$SERVICE_HOME" == "$SERVICE_HOME_DEFAULT" ]] || [[ "$SERVICE_HOME" == "$SERVICE_HOME_LEGACY" ]] || [[ "$SERVICE_HOME" == "/home/$SERVICE_USER" ]]; then + write_service_user_marker "$SERVICE_USER" "$SERVICE_HOME" + fi + fi return fi @@ -398,6 +403,7 @@ ensure_service_user() { fi if [[ "$SERVICE_USER_CREATED" == "true" ]]; then log_success "Created service user: $SERVICE_USER" + write_service_user_marker "$SERVICE_USER_ACTUAL" "$SERVICE_HOME" fi return fi @@ -444,6 +450,78 @@ get_user_home_path() { fi } +write_service_user_marker() { + local user="$1" + local home="$2" + local dir + dir=$(dirname "$SERVICE_USER_MARKER") + mkdir -p "$dir" + + local tmp + if command -v mktemp >/dev/null 2>&1; then + tmp=$(mktemp "${SERVICE_USER_MARKER}.XXXXXX") + else + tmp="${SERVICE_USER_MARKER}.tmp.$$" + fi + { + printf 'user=%s\n' "$user" + if [[ -n "$home" ]]; then + printf 'home=%s\n' "$home" + fi + } > "$tmp" + chmod 600 "$tmp" >/dev/null 2>&1 || true + mv "$tmp" "$SERVICE_USER_MARKER" +} + +service_user_managed_by_installer() { + if [[ ! -f "$SERVICE_USER_MARKER" ]]; then + return 1 + fi + + local recorded_user + recorded_user=$(grep -m1 '^user=' "$SERVICE_USER_MARKER" 2>/dev/null | cut -d= -f2-) + if [[ "$recorded_user" == "$SERVICE_USER" ]]; then + return 0 + fi + + return 1 +} + +remove_service_user_if_managed() { + if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then + rm -f "$SERVICE_USER_MARKER" >/dev/null 2>&1 || true + return + fi + + if ! service_user_managed_by_installer; then + log_info "Preserving existing service user $SERVICE_USER (not created by installer)" + return + fi + + if command -v pgrep >/dev/null 2>&1; then + if pgrep -u "$SERVICE_USER" >/dev/null 2>&1; then + log_warn "Service user $SERVICE_USER still owns running processes; skipping removal" + return + fi + fi + + if command -v userdel >/dev/null 2>&1; then + if userdel -r "$SERVICE_USER" >/dev/null 2>&1; then + log_success "Removed service user: $SERVICE_USER" + rm -f "$SERVICE_USER_MARKER" >/dev/null 2>&1 || true + return + fi + elif command -v deluser >/dev/null 2>&1; then + if deluser --remove-home "$SERVICE_USER" >/dev/null 2>&1; then + log_success "Removed service user: $SERVICE_USER" + rm -f "$SERVICE_USER_MARKER" >/dev/null 2>&1 || true + return + fi + fi + + log_warn "Failed to remove service user $SERVICE_USER; remove manually if desired" +} + ensure_snap_home_compatibility() { if [[ "$SERVICE_USER_ACTUAL" == "root" ]]; then return @@ -486,11 +564,21 @@ ensure_snap_home_compatibility() { detect_snap_docker() { SNAP_DOCKER_DETECTED="false" - # Check if the active docker binary resolves to a snap path - if command -v docker >/dev/null 2>&1; then - local docker_path - docker_path=$(readlink -f "$(command -v docker)" 2>/dev/null || echo "") - if [[ "$docker_path" == /snap/* ]] || [[ "$docker_path" == /var/lib/snapd/snap/* ]]; then + local docker_cmd docker_resolved + docker_cmd="$(command -v docker 2>/dev/null || true)" + if [[ -n "$docker_cmd" ]]; then + if [[ "$docker_cmd" == /snap/* ]] || [[ "$docker_cmd" == /var/lib/snapd/snap/* ]]; then + SNAP_DOCKER_DETECTED="true" + fi + + docker_resolved=$(readlink -f "$docker_cmd" 2>/dev/null || echo "") + if [[ "$docker_resolved" == /snap/* ]] || [[ "$docker_resolved" == /var/lib/snapd/snap/* ]]; then + SNAP_DOCKER_DETECTED="true" + fi + fi + + if [[ "$SNAP_DOCKER_DETECTED" != "true" ]] && command -v snap >/dev/null 2>&1; then + if snap list docker >/dev/null 2>&1; then SNAP_DOCKER_DETECTED="true" fi fi @@ -960,6 +1048,7 @@ SYSTEMD_SUPPLEMENTARY_GROUPS_LINE="" SNAP_DOCKER_DETECTED="false" SNAP_DOCKER_GROUP_CREATED="false" DOCKER_SOCKET_PATHS="/var/run/docker.sock" +SERVICE_USER_MARKER="/etc/pulse/.pulse-docker-agent.user" # Parse arguments while [[ $# -gt 0 ]]; do @@ -1304,6 +1393,7 @@ if [ "$UNINSTALL" = true ]; then else log_info "Agent log file already absent: $LOG_PATH" fi + remove_service_user_if_managed if [[ -n "$SERVICE_HOME" && "$SERVICE_HOME" != "/" && -d "$SERVICE_HOME" ]]; then rm -rf "$SERVICE_HOME" log_success "Removed service home directory: $SERVICE_HOME"