From 5caf75cdb13d2e83478492a6e9e5e8b7f1e45357 Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Mon, 22 Dec 2025 17:32:30 +0400 Subject: [PATCH 1/3] adding supervisor mode --- Dockerfile | 47 +- cmd/pd-agent/main.go | 70 ++- go.mod | 30 +- go.sum | 73 ++- pkg/supervisor/binary_updater.go | 388 +++++++++++++++ pkg/supervisor/config.go | 262 ++++++++++ pkg/supervisor/docker.go | 265 +++++++++++ pkg/supervisor/docker_provider.go | 114 +++++ pkg/supervisor/installer.go | 197 ++++++++ pkg/supervisor/installer_darwin.go | 64 +++ pkg/supervisor/installer_darwin_stub.go | 19 + pkg/supervisor/installer_linux.go | 390 +++++++++++++++ pkg/supervisor/installer_linux_stub.go | 19 + pkg/supervisor/installer_windows.go | 70 +++ pkg/supervisor/installer_windows_stub.go | 19 + pkg/supervisor/kubernetes_provider.go | 577 +++++++++++++++++++++++ pkg/supervisor/monitor.go | 118 +++++ pkg/supervisor/options.go | 10 + pkg/supervisor/provider.go | 72 +++ pkg/supervisor/supervisor.go | 336 +++++++++++++ pkg/supervisor/updater.go | 149 ++++++ pkg/version/version.go | 12 + 22 files changed, 3275 insertions(+), 26 deletions(-) create mode 100644 pkg/supervisor/binary_updater.go create mode 100644 pkg/supervisor/config.go create mode 100644 pkg/supervisor/docker.go create mode 100644 pkg/supervisor/docker_provider.go create mode 100644 pkg/supervisor/installer.go create mode 100644 pkg/supervisor/installer_darwin.go create mode 100644 pkg/supervisor/installer_darwin_stub.go create mode 100644 pkg/supervisor/installer_linux.go create mode 100644 pkg/supervisor/installer_linux_stub.go create mode 100644 pkg/supervisor/installer_windows.go create mode 100644 pkg/supervisor/installer_windows_stub.go create mode 100644 pkg/supervisor/kubernetes_provider.go create mode 100644 pkg/supervisor/monitor.go create mode 100644 pkg/supervisor/options.go create mode 100644 pkg/supervisor/provider.go create mode 100644 pkg/supervisor/supervisor.go create mode 100644 pkg/supervisor/updater.go create mode 100644 pkg/version/version.go diff --git a/Dockerfile b/Dockerfile index 02686bf..d3795f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,17 +2,17 @@ FROM --platform=linux/amd64 golang:1.25 AS builder RUN apt-get update && apt-get install -y git libpcap-dev -# Tools dependencies +# Tools dependencies with optimization flags # dnsx -RUN go install -v github.com/projectdiscovery/dnsx/cmd/dnsx@latest +RUN go install -ldflags="-s -w" github.com/projectdiscovery/dnsx/cmd/dnsx@latest # naabu -RUN go install -v github.com/projectdiscovery/naabu/v2/cmd/naabu@latest +RUN go install -ldflags="-s -w" github.com/projectdiscovery/naabu/v2/cmd/naabu@latest # httpx -RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest +RUN go install -ldflags="-s -w" github.com/projectdiscovery/httpx/cmd/httpx@latest # tlsx -RUN go install -v github.com/projectdiscovery/tlsx/cmd/tlsx@latest +RUN go install -ldflags="-s -w" github.com/projectdiscovery/tlsx/cmd/tlsx@latest # nuclei -RUN go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest +RUN go install -ldflags="-s -w" github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest # Copy source code @@ -25,22 +25,28 @@ COPY . . # CGO_ENABLED=1 is required for libpcap/gopacket support (passive discovery feature) RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o /go/bin/pd-agent ./cmd/pd-agent/main.go -FROM --platform=linux/amd64 ubuntu:latest +# Clean Go module cache to reduce image size +RUN go clean -modcache && \ + rm -rf /root/.cache/go-build + +FROM --platform=linux/amd64 ubuntu:22.04 # install dependencies # required: libpcap-dev, chrome -RUN apt update && apt install -y \ - bind9-dnsutils \ - ca-certificates \ - nmap \ - libpcap-dev \ - wget \ - gnupg \ +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + libpcap-dev \ + wget \ + gnupg \ && wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \ && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list \ - && apt update \ - && apt install -y google-chrome-stable \ - && apt clean \ - && rm -rf /var/lib/apt/lists/* + && apt-get update \ + && apt-get install -y --no-install-recommends google-chrome-stable \ + && apt-get purge -y wget gnupg \ + && apt-get autoremove -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /usr/share/doc /usr/share/man /usr/share/locale /usr/share/info # Set environment variables for Chrome ENV CHROME_BIN=/usr/bin/google-chrome-stable @@ -57,8 +63,9 @@ COPY --from=builder /go/bin/nuclei /usr/local/bin/ # Copy agent binary COPY --from=builder /go/bin/pd-agent /usr/local/bin/pd-agent -# Create writable output directory for existing ubuntu user (UID 1000) -RUN mkdir -p /home/ubuntu/output && \ +# Create ubuntu user and writable output directory +RUN useradd -m -u 1000 ubuntu && \ + mkdir -p /home/ubuntu/output && \ chown -R ubuntu:ubuntu /home/ubuntu # Set default environment variables (can be overridden at runtime) diff --git a/cmd/pd-agent/main.go b/cmd/pd-agent/main.go index a707b01..be63afb 100644 --- a/cmd/pd-agent/main.go +++ b/cmd/pd-agent/main.go @@ -29,6 +29,7 @@ import ( "github.com/projectdiscovery/pd-agent/pkg" "github.com/projectdiscovery/pd-agent/pkg/client" "github.com/projectdiscovery/pd-agent/pkg/scanlog" + "github.com/projectdiscovery/pd-agent/pkg/supervisor" "github.com/projectdiscovery/pd-agent/pkg/types" "github.com/projectdiscovery/utils/batcher" envutil "github.com/projectdiscovery/utils/env" @@ -69,7 +70,7 @@ func getAllNucleiTemplates(templateDir string) ([]string, error) { var ( PDCPApiKey = envutil.GetEnvOrDefault("PDCP_API_KEY", "") TeamIDEnv = envutil.GetEnvOrDefault("PDCP_TEAM_ID", "") - AgentTagsEnv = envutil.GetEnvOrDefault("PDCP_AGENT_TAGS", "default") + AgentTagsEnv = envutil.GetEnvOrDefault("PDCP_AGENT_TAGS", "") PdcpApiServer = envutil.GetEnvOrDefault("PDCP_API_SERVER", "https://api.projectdiscovery.io") ChunkParallelismEnv = envutil.GetEnvOrDefault("PDCP_CHUNK_PARALLELISM", "1") ScanParallelismEnv = envutil.GetEnvOrDefault("PDCP_SCAN_PARALLELISM", "1") @@ -90,6 +91,7 @@ type Options struct { ScanParallelism int // Number of scans to process in parallel EnumerationParallelism int // Number of enumerations to process in parallel KeepOutputFiles bool // If true, don't delete output files after processing + SupervisorMode string // Supervisor mode: "docker" or "kubernetes" (default: empty, disabled) } // ScanCache represents cached scan execution information @@ -1429,6 +1431,11 @@ func (r *Runner) processChunks(ctx context.Context, taskID, taskType string, exe if err == nil { break } + // If we get "no more chunks", terminate immediately without retrying + if err != nil && err.Error() == "no more chunks" { + r.logHelper("INFO", fmt.Sprintf("No more chunks available for %s ID: %s", taskType, taskID)) + goto Complete + } currentErr := err.Error() if currentErr == lastErr { // If we get the same error multiple times, likely the task is complete @@ -1896,6 +1903,11 @@ func (r *Runner) getTaskChunk(ctx context.Context, taskID string, done bool) (*T return nil, fmt.Errorf("error unmarshaling response: %v", err) } + // Check if the unmarshaled struct is empty (no more chunks) + if taskChunk.ChunkID == "" { + return nil, fmt.Errorf("no more chunks") + } + return &taskChunk, nil } @@ -2597,12 +2609,21 @@ func parseOptions() *Options { flagSet.IntVarP(&options.ChunkParallelism, "chunk-parallelism", "c", defaultChunkParallelism, "number of chunks to process in parallel"), flagSet.IntVarP(&options.ScanParallelism, "scan-parallelism", "s", defaultScanParallelism, "number of scans to process in parallel"), flagSet.IntVarP(&options.EnumerationParallelism, "enumeration-parallelism", "e", defaultEnumerationParallelism, "number of enumerations to process in parallel"), + flagSet.StringVar(&options.SupervisorMode, "supervisor-mode", "", "run as supervisor: \"docker\" or \"kubernetes\" (default: empty, disabled)"), ) if err := flagSet.Parse(); err != nil { slog.Error("error", "error", err) } + // Validate supervisor mode + if options.SupervisorMode != "" { + if options.SupervisorMode != "docker" && options.SupervisorMode != "kubernetes" { + slog.Error("Invalid supervisor mode", "mode", options.SupervisorMode, "valid", "docker or kubernetes") + options.SupervisorMode = "" // disable supervisor mode if invalid + } + } + // Parse environment variables (env vars take precedence as defaults) if agentTags := os.Getenv("PDCP_AGENT_TAGS"); agentTags != "" && len(options.AgentTags) == 0 { options.AgentTags = goflags.StringSlice(strings.Split(agentTags, ",")) @@ -2681,6 +2702,12 @@ func main() { options := parseOptions() + // If supervisor mode is enabled, run supervisor instead of direct agent + if options.SupervisorMode != "" { + runSupervisorMode(options) + return + } + // Check prerequisites before starting the agent prerequisites := pkg.CheckAllPrerequisites() var missingTools []string @@ -2718,3 +2745,44 @@ func main() { os.Exit(1) } } + +// runSupervisorMode runs the agent in supervisor mode +func runSupervisorMode(options *Options) { + // Convert Options to supervisor.AgentOptions + agentOptions := &supervisor.AgentOptions{ + TeamID: options.TeamID, + AgentID: options.AgentId, + AgentTags: []string(options.AgentTags), + AgentNetworks: []string(options.AgentNetworks), + AgentOutput: options.AgentOutput, + AgentName: options.AgentName, + Verbose: options.Verbose, + PassiveDiscovery: options.PassiveDiscovery, + ChunkParallelism: options.ChunkParallelism, + ScanParallelism: options.ScanParallelism, + EnumerationParallelism: options.EnumerationParallelism, + KeepOutputFiles: options.KeepOutputFiles, + } + + // Generate agent ID if not set + if agentOptions.AgentID == "" { + agentOptions.AgentID = xid.New().String() + } + + // Create supervisor with specified provider + sup, err := supervisor.NewSupervisorWithProvider(agentOptions, options.SupervisorMode) + if err != nil { + gologger.Fatal().Msgf("Could not create supervisor: %v", err) + os.Exit(1) + } + + // Setup signal handlers + ctx := sup.SetupSignalHandlers(context.Background()) + + // Run supervisor + if err := sup.Run(ctx); err != nil { + gologger.Fatal().Msgf("Supervisor error: %v", err) + os.Exit(1) + } + gologger.Info().Msg("Supervisor terminated") +} diff --git a/go.mod b/go.mod index 63c551b..260fca5 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,13 @@ module github.com/projectdiscovery/pd-agent go 1.24.2 require ( + github.com/Masterminds/semver/v3 v3.4.0 + github.com/docker/go-sdk/client v0.1.0-alpha012 github.com/dustin/go-humanize v1.0.1 + github.com/google/go-github/v62 v62.0.0 github.com/google/gopacket v1.1.19 + github.com/moby/moby/api v1.52.0 + github.com/moby/moby/client v0.1.0 github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c github.com/projectdiscovery/goflags v0.1.74 github.com/projectdiscovery/gologger v1.1.61 @@ -12,6 +17,7 @@ require ( github.com/projectdiscovery/naabu/v2 v2.3.7 github.com/projectdiscovery/nuclei/v3 v3.5.1 github.com/projectdiscovery/utils v0.7.3 + github.com/rhysd/go-github-selfupdate v1.2.3 github.com/rs/xid v1.6.0 github.com/shirou/gopsutil/v3 v3.24.5 github.com/tidwall/gjson v1.18.0 @@ -23,7 +29,7 @@ require ( require ( aead.dev/minisign v0.2.0 // indirect - github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 // indirect github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect github.com/STARRY-S/zip v0.2.3 // indirect @@ -35,11 +41,13 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/caarlos0/env/v11 v11.3.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/glamour v0.10.0 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect @@ -51,16 +59,24 @@ require ( github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-sdk/config v0.1.0-alpha012 // indirect + github.com/docker/go-sdk/context v0.1.0-alpha012 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.26.0 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect @@ -76,6 +92,7 @@ require ( github.com/gopacket/gopacket v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.2 // indirect @@ -94,14 +111,18 @@ require ( github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.1 // indirect github.com/minio/selfupdate v0.6.1-0.20230907112617-f11e74f84ca7 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nwaples/rardecode/v2 v2.2.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/projectdiscovery/asnmap v1.1.1 // indirect github.com/projectdiscovery/blackrock v0.0.1 // indirect @@ -127,7 +148,9 @@ require ( github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/syndtr/goleveldb v1.0.0 // indirect + github.com/tcnksm/go-gitconfig v0.1.2 // indirect github.com/tidwall/btree v1.7.0 // indirect github.com/tidwall/buntdb v1.3.1 // indirect github.com/tidwall/grect v0.1.4 // indirect @@ -150,6 +173,11 @@ require ( github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248 // indirect github.com/zmap/zcrypto v0.0.0-20240512203510-0fef58d9a9db // indirect go.etcd.io/bbolt v1.4.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index 49c46af..6273457 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,10 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 h1:KFac3SiGbId8ub47e7kd2PLZeACxc1LkiiNoDOFRClE= github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057/go.mod h1:iLB2pivrPICvLOuROKmlqURtFIEsoJZaMidQfCG1+D4= github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 h1:ZbFL+BDfBqegi+/Ssh7im5+aQfBRx6it+kHnC7jaDU8= @@ -55,6 +57,8 @@ github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJR github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bloom/v3 v3.5.0 h1:AKDvi1V3xJCmSR6QhcBfHbCN4Vf8FfxeWkMNQfmAGhY= github.com/bits-and-blooms/bloom/v3 v3.5.0/go.mod h1:Y8vrn7nk1tPIlmLtW2ZPV+W7StdVMor6bC1xgpjMZFs= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= @@ -67,6 +71,8 @@ github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxl github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= @@ -98,14 +104,28 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a h1:Ohw57yVY2dBTt+gsC6aZdteyxwlxfbtgkFEMTEkwgSw= github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a/go.mod h1:pCxVEbcm3AMg7ejXyorUXi6HQCzOIBf7zEDVPtw0/U4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-sdk/client v0.1.0-alpha012 h1:5EvN9F3d7JLvalJiwB3VuDBC52eErRRILiyhonwcTS8= +github.com/docker/go-sdk/client v0.1.0-alpha012/go.mod h1:5sJRpJNv6MKvM07b/sez85InpmnCQnwwXJ8nwr5meUk= +github.com/docker/go-sdk/config v0.1.0-alpha012 h1:LlLBZQwlggO7jvUwkIcvzzgLNxB0HSgxhxPlsgysVus= +github.com/docker/go-sdk/config v0.1.0-alpha012/go.mod h1:Eq39AVh06RUJ50fLvJHFKM80P9kZWTiog1x7NzrndBg= +github.com/docker/go-sdk/context v0.1.0-alpha012 h1:977FC+15aKg3KxA+VqvzEHANcKRTgs8Q8oaXq5CQ+ro= +github.com/docker/go-sdk/context v0.1.0-alpha012/go.mod h1:UJfIj4J1ogiYPUSt+W0NLM5OWgpYHJEQVI9dHhQYss8= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= @@ -119,6 +139,8 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -130,8 +152,11 @@ github.com/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0= github.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -181,6 +206,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo= github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= +github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= +github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= @@ -212,6 +239,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -267,6 +296,12 @@ github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/minio/selfupdate v0.6.1-0.20230907112617-f11e74f84ca7 h1:yRZGarbxsRytL6EGgbqK2mCY+Lk5MWKQYKJT2gEglhc= github.com/minio/selfupdate v0.6.1-0.20230907112617-f11e74f84ca7/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.52.0 h1:00BtlJY4MXkkt84WhUZPRqt5TvPbgig2FZvTbe3igYg= +github.com/moby/moby/api v1.52.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= +github.com/moby/moby/client v0.1.0 h1:nt+hn6O9cyJQqq5UWnFGqsZRTS/JirUqzPjEl0Bdc/8= +github.com/moby/moby/client v0.1.0/go.mod h1:O+/tw5d4a1Ha/ZA/tPxIZJapJRUS6LNZ1wiVRxYHyUE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -291,10 +326,15 @@ github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -346,8 +386,6 @@ github.com/projectdiscovery/retryablehttp-go v1.0.132 h1:h4sVcJE9GsLnxfzyXy7pa1P github.com/projectdiscovery/retryablehttp-go v1.0.132/go.mod h1:vf8+meeaGFjglVSDQvNISQtAmDKpi4FDjyb4+eFUED4= github.com/projectdiscovery/uncover v1.1.0 h1:UDp/qLZn78YZb6VPoOrfyP1vz+ojEx8VrTTyjjRt9UU= github.com/projectdiscovery/uncover v1.1.0/go.mod h1:2rXINmMe/lmVAt2jn9CpAOs9An57/JEeLZobY3Z9kUs= -github.com/projectdiscovery/mapcidr v1.1.97 h1:7FkxNNVXp+m1rIu5Nv/2SrF9k4+LwP8QuWs2puwy+2w= -github.com/projectdiscovery/mapcidr v1.1.97/go.mod h1:9dgTJh1SP02gYZdpzMjm6vtYFkEHQHoTyaVNvaeJ7lA= github.com/projectdiscovery/utils v0.7.3 h1:kX+77AA58yK6EZgkTRJEnK9V/7AZYzlXdcu/o/kJhFs= github.com/projectdiscovery/utils v0.7.3/go.mod h1:uDdQ3/VWomai98l+a3Ye/srDXdJ4xUIar/mSXlQ9gBM= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -355,6 +393,8 @@ github.com/refraction-networking/utls v1.7.1 h1:dxg+jla3uocgN8HtX+ccwDr68uCBBO3q github.com/refraction-networking/utls v1.7.1/go.mod h1:TUhh27RHMGtQvjQq+RyO11P6ZNQNBb3N0v7wsEjKAIQ= github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= +github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag= +github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -399,6 +439,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw= +github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE= github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI= github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8= github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI= @@ -429,6 +471,7 @@ github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XV github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= @@ -471,6 +514,20 @@ go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= @@ -489,6 +546,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -562,6 +620,7 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -633,6 +692,7 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -690,6 +750,7 @@ google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsb google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= @@ -742,6 +803,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -760,6 +823,8 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbF k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/pkg/supervisor/binary_updater.go b/pkg/supervisor/binary_updater.go new file mode 100644 index 0000000..b33ff71 --- /dev/null +++ b/pkg/supervisor/binary_updater.go @@ -0,0 +1,388 @@ +package supervisor + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/google/go-github/v62/github" + "github.com/projectdiscovery/gologger" + "github.com/projectdiscovery/pd-agent/pkg/version" + "github.com/rhysd/go-github-selfupdate/selfupdate" +) + +// BinaryUpdater handles self-updates of the pd-agent binary +type BinaryUpdater struct { + repoOwner string + repoName string + currentVersion string + checkInterval time.Duration + lastCheck time.Time +} + +// UpdateInfo contains information about an available update +type UpdateInfo struct { + Version string + ReleaseURL string + AssetURL string + AssetName string + ReleaseNotes string +} + +// NewBinaryUpdater creates a new binary updater +func NewBinaryUpdater(repoOwner, repoName string) *BinaryUpdater { + currentVersion := strings.TrimPrefix(version.GetVersion(), "v") + + return &BinaryUpdater{ + repoOwner: repoOwner, + repoName: repoName, + currentVersion: currentVersion, + checkInterval: 24 * time.Hour, // Check once per day + } +} + +// CheckForUpdate checks for available updates (only stable releases) +func (b *BinaryUpdater) CheckForUpdate(ctx context.Context) (*UpdateInfo, error) { + gologger.Info().Msg("self-update: checking for available updates") + gologger.Info().Msgf("self-update: current version is %s", b.currentVersion) + + // Use GitHub API to get latest stable (non-prerelease) release + gologger.Info().Msgf("self-update: fetching releases from GitHub (%s/%s)", b.repoOwner, b.repoName) + client := github.NewClient(nil) + releases, _, err := client.Repositories.ListReleases(ctx, b.repoOwner, b.repoName, &github.ListOptions{ + PerPage: 10, // Check first 10 releases + }) + if err != nil { + return nil, fmt.Errorf("failed to list releases: %w", err) + } + gologger.Info().Msgf("self-update: found %d releases", len(releases)) + + if len(releases) == 0 { + gologger.Info().Msg("self-update: no releases found") + return nil, nil // No releases found + } + + // Find first non-prerelease release + gologger.Info().Msg("self-update: filtering for stable (non-prerelease) releases") + var latestStable *github.RepositoryRelease + for _, release := range releases { + if release.Prerelease == nil || !*release.Prerelease { + latestStable = release + break + } + } + + if latestStable == nil { + gologger.Info().Msg("self-update: no stable releases found") + return nil, nil // No stable releases found + } + + // Parse version from tag (remove 'v' prefix if present) + tagName := latestStable.GetTagName() + versionStr := strings.TrimPrefix(tagName, "v") + gologger.Info().Msgf("self-update: latest stable release is %s", versionStr) + + // Filter out pre-releases by checking version string (additional check) + if strings.Contains(versionStr, "-") { + gologger.Info().Msgf("self-update: skipping pre-release version %s", versionStr) + return nil, nil + } + + // Parse current version + gologger.Info().Msgf("self-update: comparing versions (current: %s, latest: %s)", b.currentVersion, versionStr) + currentVer, err := semver.NewVersion(b.currentVersion) + if err != nil { + // If current version can't be parsed, assume it's a dev build and allow update + gologger.Info().Msgf("self-update: current version '%s' is not a valid semver, allowing update", b.currentVersion) + } else { + // Compare versions - only update if newer + latestVer, err := semver.NewVersion(versionStr) + if err != nil { + return nil, fmt.Errorf("failed to parse latest version: %w", err) + } + + if !latestVer.GreaterThan(currentVer) { + gologger.Info().Msgf("self-update: already running latest version (%s)", b.currentVersion) + return nil, nil // No update available + } + gologger.Info().Msgf("self-update: update available (current: %s, latest: %s)", b.currentVersion, versionStr) + } + + // Log available assets for debugging + if len(latestStable.Assets) > 0 { + gologger.Info().Msgf("self-update: release has %d assets:", len(latestStable.Assets)) + for _, asset := range latestStable.Assets { + gologger.Info().Msgf("self-update: - %s (%s)", asset.GetName(), asset.GetContentType()) + } + } else { + gologger.Warning().Msg("self-update: release has no assets") + return nil, fmt.Errorf("release has no assets") + } + + // Try to find asset manually using expected naming pattern + // Pattern: pd-agent_{version}_{OS}_{ARCH}.zip (e.g., pd-agent_0.1.1_macOS_arm64.zip) + osName := runtime.GOOS + if osName == "darwin" { + osName = "macOS" + } + arch := runtime.GOARCH + expectedAssetName := fmt.Sprintf("pd-agent_%s_%s_%s.zip", versionStr, osName, arch) + gologger.Info().Msgf("self-update: looking for asset matching pattern: %s", expectedAssetName) + + var matchingAsset *github.ReleaseAsset + for _, asset := range latestStable.Assets { + if asset.GetName() == expectedAssetName { + matchingAsset = asset + gologger.Info().Msgf("self-update: found matching asset: %s", asset.GetName()) + break + } + } + + if matchingAsset == nil { + // Try alternative patterns + altPatterns := []string{ + fmt.Sprintf("pd-agent_%s_%s_%s.zip", versionStr, runtime.GOOS, runtime.GOARCH), + fmt.Sprintf("pd-agent_%s_%s_%s.tar.gz", versionStr, osName, arch), + fmt.Sprintf("pd-agent_%s_%s_%s.tar.gz", versionStr, runtime.GOOS, runtime.GOARCH), + } + for _, pattern := range altPatterns { + for _, asset := range latestStable.Assets { + if asset.GetName() == pattern { + matchingAsset = asset + gologger.Info().Msgf("self-update: found matching asset with alternative pattern: %s", asset.GetName()) + break + } + } + if matchingAsset != nil { + break + } + } + } + + if matchingAsset == nil { + gologger.Error().Msgf("self-update: no matching asset found for platform %s/%s", runtime.GOOS, runtime.GOARCH) + return nil, fmt.Errorf("no matching asset found for platform %s/%s", runtime.GOOS, runtime.GOARCH) + } + + // Use manually found asset instead of library's DetectVersion + // The library expects different naming patterns, so we find the asset ourselves + gologger.Info().Msgf("self-update: using manually found asset: %s", matchingAsset.GetName()) + + return &UpdateInfo{ + Version: versionStr, + ReleaseURL: latestStable.GetHTMLURL(), + AssetURL: matchingAsset.GetBrowserDownloadURL(), + AssetName: matchingAsset.GetName(), + ReleaseNotes: latestStable.GetBody(), + }, nil +} + +// Update downloads and installs the latest version +func (b *BinaryUpdater) Update(ctx context.Context) error { + // First check for update to get the version + updateInfo, err := b.CheckForUpdate(ctx) + if err != nil { + return fmt.Errorf("failed to check for update: %w", err) + } + + if updateInfo == nil { + gologger.Info().Msg("self-update: already running latest version") + return nil + } + + gologger.Info().Msgf("self-update: update available (%s -> %s)", b.currentVersion, updateInfo.Version) + gologger.Info().Msgf("self-update: downloading update from %s", updateInfo.ReleaseURL) + + // Get current executable path + gologger.Info().Msg("self-update: getting current executable path") + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + gologger.Info().Msgf("self-update: executable path: %s", exe) + + // Create backup + backupPath := exe + ".backup" + gologger.Info().Msg("self-update: creating backup of current binary") + if err := b.createBackup(exe, backupPath); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + gologger.Info().Msgf("self-update: backup created at %s", backupPath) + + // Use library's UpdateTo with the asset URL we found manually + // The library will handle download, extraction, and installation + gologger.Info().Msgf("self-update: downloading and installing from asset URL: %s", updateInfo.AssetURL) + gologger.Info().Msgf("self-update: asset name: %s", updateInfo.AssetName) + + if err := selfupdate.UpdateTo(updateInfo.AssetURL, exe); err != nil { + gologger.Error().Msgf("self-update: failed to install update, restoring backup: %v", err) + // Restore backup on failure + if restoreErr := b.restoreBackup(backupPath, exe); restoreErr != nil { + gologger.Error().Msgf("self-update: failed to restore backup: %v", restoreErr) + } else { + gologger.Info().Msg("self-update: backup restored successfully") + } + return fmt.Errorf("failed to update: %w", err) + } + + gologger.Info().Msgf("self-update: successfully updated to version %s", updateInfo.Version) + + // Clean up backup after successful update + gologger.Info().Msg("self-update: cleaning up backup file") + _ = os.Remove(backupPath) + + return nil +} + +// StartUpdateLoop starts the periodic update check loop +func (b *BinaryUpdater) StartUpdateLoop(ctx context.Context, updateCallback func() error) { + ticker := time.NewTicker(b.checkInterval) + defer ticker.Stop() + + // Initial check + b.checkAndUpdate(ctx, updateCallback) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + b.checkAndUpdate(ctx, updateCallback) + } + } +} + +// checkAndUpdate checks for updates and applies them if available +func (b *BinaryUpdater) checkAndUpdate(ctx context.Context, updateCallback func() error) { + // Prevent multiple concurrent update checks + now := time.Now() + if now.Sub(b.lastCheck) < 1*time.Hour { + gologger.Verbose().Msg("self-update: skipping check (already checked recently)") + return // Already checked recently + } + b.lastCheck = now + + gologger.Info().Msg("self-update: starting update check") + updateInfo, err := b.CheckForUpdate(ctx) + if err != nil { + gologger.Warning().Msgf("self-update: failed to check for updates: %v", err) + return + } + + if updateInfo == nil { + gologger.Info().Msg("self-update: no update available") + return + } + + gologger.Info().Msgf("self-update: update available: version %s", updateInfo.Version) + + // Perform update + gologger.Info().Msg("self-update: starting update process") + if err := b.Update(ctx); err != nil { + gologger.Error().Msgf("self-update: failed to update binary: %v", err) + return + } + + // Restart the application + gologger.Info().Msg("self-update: restarting application with new binary") + if err := b.restartApplication(); err != nil { + gologger.Error().Msgf("self-update: failed to restart application: %v", err) + } +} + +// createBackup creates a backup of the current binary +func (b *BinaryUpdater) createBackup(src, dst string) error { + gologger.Info().Msgf("self-update: creating backup at %s", dst) + + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer func() { + _ = srcFile.Close() + }() + + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer func() { + _ = dstFile.Close() + }() + + _, err = dstFile.ReadFrom(srcFile) + if err != nil { + _ = os.Remove(dst) + return err + } + + // Make backup executable + if err := os.Chmod(dst, 0755); err != nil { + gologger.Warning().Msgf("Failed to set backup permissions: %v", err) + } + + return nil +} + +// restoreBackup restores the binary from backup +func (b *BinaryUpdater) restoreBackup(backup, target string) error { + gologger.Info().Msgf("self-update: restoring from backup: %s", backup) + + backupFile, err := os.Open(backup) + if err != nil { + return err + } + defer func() { + _ = backupFile.Close() + }() + + targetFile, err := os.Create(target) + if err != nil { + return err + } + defer func() { + _ = targetFile.Close() + }() + + _, err = targetFile.ReadFrom(backupFile) + if err != nil { + return err + } + + // Make restored binary executable + if err := os.Chmod(target, 0755); err != nil { + gologger.Warning().Msgf("Failed to set restored binary permissions: %v", err) + } + + return nil +} + +// restartApplication restarts the current application +func (b *BinaryUpdater) restartApplication() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + // Get command-line arguments (excluding the executable path) + args := os.Args[1:] + + // Start new process + cmd := exec.Command(exe, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start new process: %w", err) + } + + // Exit current process + os.Exit(0) + return nil +} diff --git a/pkg/supervisor/config.go b/pkg/supervisor/config.go new file mode 100644 index 0000000..f26c8dd --- /dev/null +++ b/pkg/supervisor/config.go @@ -0,0 +1,262 @@ +package supervisor + +import ( + "fmt" + "os" + "strings" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" +) + +// ContainerConfig represents Docker container configuration +type ContainerConfig struct { + Image string + Name string + Env []string + Volumes []string + NetworkMode string + CapAdd []string + Cmd []string + Restart string +} + +// AgentOptions represents agent configuration options for supervisor +type AgentOptions struct { + TeamID string + AgentID string + AgentTags []string + AgentNetworks []string + AgentOutput string + AgentName string + Verbose bool + PassiveDiscovery bool + ChunkParallelism int + ScanParallelism int + EnumerationParallelism int + KeepOutputFiles bool +} + +// BuildContainerConfig builds container configuration from agent options +func BuildContainerConfig(options *AgentOptions, agentID string) *ContainerConfig { + deploymentConfig := BuildDeploymentConfig(options, agentID) + return deploymentConfig.ToContainerConfig() +} + +// BuildDeploymentConfig builds deployment configuration from agent options +func BuildDeploymentConfig(options *AgentOptions, agentID string) *DeploymentConfig { + // Use hardcoded Docker image + image := "projectdiscovery/pd-agent:latest" + + // Generate container name using xid (same approach as agent ID) + containerName := fmt.Sprintf("pd-agent-%s", agentID) + + // Build environment variables + env := buildEnvVars(options) + + // Build volumes + volumes := buildVolumes(options) + + // Build command arguments (excluding supervisor-mode flag) + cmd := buildCommandArgs(options) + + config := &DeploymentConfig{ + Image: image, + Name: containerName, + Env: env, + Volumes: volumes, + NetworkMode: "host", // Required for subnet discovery + CapAdd: []string{"NET_RAW", "NET_ADMIN"}, + Cmd: cmd, + Restart: "no", // We manage restart ourselves + } + + return config +} + +// ToContainerConfig converts DeploymentConfig to ContainerConfig +func (d *DeploymentConfig) ToContainerConfig() *ContainerConfig { + return &ContainerConfig{ + Image: d.Image, + Name: d.Name, + Env: d.Env, + Volumes: d.Volumes, + NetworkMode: d.NetworkMode, + CapAdd: d.CapAdd, + Cmd: d.Cmd, + Restart: d.Restart, + } +} + +// ToDockerConfig converts ContainerConfig to Docker API types +func (c *ContainerConfig) ToDockerConfig() (*container.Config, *container.HostConfig) { + containerConfig := &container.Config{ + Image: c.Image, + Env: c.Env, + Cmd: c.Cmd, + } + + hostConfig := &container.HostConfig{ + NetworkMode: container.NetworkMode(c.NetworkMode), + CapAdd: c.CapAdd, + RestartPolicy: container.RestartPolicy{ + Name: container.RestartPolicyMode(c.Restart), + }, + } + + // Add volume mounts + if len(c.Volumes) > 0 { + mounts := make([]mount.Mount, 0) + for _, vol := range c.Volumes { + parts := strings.Split(vol, ":") + if len(parts) == 2 { + mounts = append(mounts, mount.Mount{ + Type: mount.TypeBind, + Source: parts[0], + Target: parts[1], + }) + } + } + hostConfig.Mounts = mounts + } + + return containerConfig, hostConfig +} + +// buildEnvVars builds environment variables for container +func buildEnvVars(options *AgentOptions) []string { + env := []string{} + + // Pass through all PDCP_* environment variables + for _, e := range os.Environ() { + if strings.HasPrefix(e, "PDCP_") { + env = append(env, e) + } + } + + // Add agent-specific environment variables if not already set + addEnvIfNotExists(&env, "PDCP_API_KEY", os.Getenv("PDCP_API_KEY")) + addEnvIfNotExists(&env, "PDCP_TEAM_ID", options.TeamID) + + if len(options.AgentTags) > 0 { + addEnvIfNotExists(&env, "PDCP_AGENT_TAGS", strings.Join(options.AgentTags, ",")) + } + + if len(options.AgentNetworks) > 0 { + addEnvIfNotExists(&env, "PDCP_AGENT_NETWORKS", strings.Join(options.AgentNetworks, ",")) + } + + if options.AgentName != "" { + addEnvIfNotExists(&env, "PDCP_AGENT_NAME", options.AgentName) + } + + if options.AgentOutput != "" { + addEnvIfNotExists(&env, "PDCP_AGENT_OUTPUT", options.AgentOutput) + } + + if options.Verbose { + addEnvIfNotExists(&env, "PDCP_VERBOSE", "true") + } + + // Add parallelism settings + if options.ChunkParallelism > 0 { + addEnvIfNotExists(&env, "PDCP_CHUNK_PARALLELISM", fmt.Sprintf("%d", options.ChunkParallelism)) + } + if options.ScanParallelism > 0 { + addEnvIfNotExists(&env, "PDCP_SCAN_PARALLELISM", fmt.Sprintf("%d", options.ScanParallelism)) + } + if options.EnumerationParallelism > 0 { + addEnvIfNotExists(&env, "PDCP_ENUMERATION_PARALLELISM", fmt.Sprintf("%d", options.EnumerationParallelism)) + } + + // Pass through PROXY_URL if set + addEnvIfNotExists(&env, "PROXY_URL", os.Getenv("PROXY_URL")) + + return env +} + +// addEnvIfNotExists adds environment variable if not already present +func addEnvIfNotExists(env *[]string, key, value string) { + if value == "" { + return + } + + // Check if already exists + for _, e := range *env { + if strings.HasPrefix(e, key+"=") { + return + } + } + + *env = append(*env, fmt.Sprintf("%s=%s", key, value)) +} + +// buildVolumes builds volume mounts for container +func buildVolumes(options *AgentOptions) []string { + volumes := []string{} + + // Mount output directory if specified + if options.AgentOutput != "" { + // Ensure the directory exists + if err := os.MkdirAll(options.AgentOutput, 0755); err == nil { + volumes = append(volumes, fmt.Sprintf("%s:%s", options.AgentOutput, options.AgentOutput)) + } + } + + return volumes +} + +// buildCommandArgs builds command arguments for container (excluding supervisor-mode) +func buildCommandArgs(options *AgentOptions) []string { + args := []string{} + + // Add verbose flag + if options.Verbose { + args = append(args, "-verbose") + } + + // Add keep-output-files flag + if options.KeepOutputFiles { + args = append(args, "-keep-output-files") + } + + // Add agent output + if options.AgentOutput != "" { + args = append(args, "-agent-output", options.AgentOutput) + } + + // Add agent tags + if len(options.AgentTags) > 0 { + args = append(args, "-agent-tags", strings.Join(options.AgentTags, ",")) + } + + // Add agent networks + if len(options.AgentNetworks) > 0 { + args = append(args, "-agent-networks", strings.Join(options.AgentNetworks, ",")) + } + + // Add agent name + if options.AgentName != "" { + args = append(args, "-agent-name", options.AgentName) + } + + // Add parallelism flags + if options.ChunkParallelism > 0 { + args = append(args, "-chunk-parallelism", fmt.Sprintf("%d", options.ChunkParallelism)) + } + if options.ScanParallelism > 0 { + args = append(args, "-scan-parallelism", fmt.Sprintf("%d", options.ScanParallelism)) + } + if options.EnumerationParallelism > 0 { + args = append(args, "-enumeration-parallelism", fmt.Sprintf("%d", options.EnumerationParallelism)) + } + + // Add passive discovery if enabled + if options.PassiveDiscovery { + args = append(args, "-passive-discovery") + } + + // Note: We explicitly exclude -supervisor-mode flag + + return args +} diff --git a/pkg/supervisor/docker.go b/pkg/supervisor/docker.go new file mode 100644 index 0000000..12a875b --- /dev/null +++ b/pkg/supervisor/docker.go @@ -0,0 +1,265 @@ +package supervisor + +import ( + "context" + "fmt" + "io" + "time" + + mobyclient "github.com/moby/moby/client" + mobyimage "github.com/moby/moby/api/types/image" + dockersdk "github.com/docker/go-sdk/client" + "github.com/projectdiscovery/gologger" +) + +// DockerClient wraps the Docker API client +type DockerClient struct { + client dockersdk.SDKClient +} + +// ContainerInfo represents container inspection information +type ContainerInfo struct { + ID string + Status string + Running bool + ExitCode int + ImageID string // Image ID the container is using +} + +// NewDockerClient creates a new Docker client +func NewDockerClient() (*DockerClient, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + dockerClient, err := dockersdk.New(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + + // Test connection + _, err = dockerClient.Ping(ctx, mobyclient.PingOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to connect to Docker daemon: %w", err) + } + + return &DockerClient{client: dockerClient}, nil +} + +// IsDockerInstalled checks if Docker is installed +func (d *DockerClient) IsDockerInstalled() bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := dockersdk.New(ctx) + return err == nil +} + +// IsDockerRunning checks if Docker daemon is running +func (d *DockerClient) IsDockerRunning() bool { + if d == nil || d.client == nil { + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := d.client.Ping(ctx, mobyclient.PingOptions{}) + return err == nil +} + +// PullImage pulls a Docker image +func (d *DockerClient) PullImage(ctx context.Context, img string) error { + gologger.Info().Msgf("Pulling Docker image: %s", img) + + reader, err := d.client.ImagePull(ctx, img, mobyclient.ImagePullOptions{}) + if err != nil { + return fmt.Errorf("failed to pull image: %w", err) + } + defer func() { + _ = reader.Close() + }() + + // Read the output to completion + _, err = io.Copy(io.Discard, reader) + if err != nil { + return fmt.Errorf("failed to read pull output: %w", err) + } + + gologger.Info().Msgf("Successfully pulled image: %s", img) + return nil +} + +// RunContainer runs a Docker container +func (d *DockerClient) RunContainer(ctx context.Context, config *ContainerConfig) (string, error) { + containerConfig, hostConfig := config.ToDockerConfig() + + // Create container + resp, err := d.client.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{ + Config: containerConfig, + HostConfig: hostConfig, + Name: config.Name, + }) + if err != nil { + return "", fmt.Errorf("failed to create container: %w", err) + } + + // Start container + if _, err := d.client.ContainerStart(ctx, resp.ID, mobyclient.ContainerStartOptions{}); err != nil { + // Clean up container if start fails + _, _ = d.client.ContainerRemove(ctx, resp.ID, mobyclient.ContainerRemoveOptions{Force: true}) + return "", fmt.Errorf("failed to start container: %w", err) + } + + gologger.Info().Msgf("Started container: %s (ID: %s)", config.Name, resp.ID[:12]) + return resp.ID, nil +} + +// StopContainer stops a Docker container +func (d *DockerClient) StopContainer(ctx context.Context, containerID string, timeout *int) error { + if timeout == nil { + defaultTimeout := 10 + timeout = &defaultTimeout + } + + gologger.Info().Msgf("Stopping container: %s", containerID[:12]) + _, err := d.client.ContainerStop(ctx, containerID, mobyclient.ContainerStopOptions{Timeout: timeout}) + if err != nil { + return fmt.Errorf("failed to stop container: %w", err) + } + + gologger.Info().Msgf("Stopped container: %s", containerID[:12]) + return nil +} + +// RemoveContainer removes a Docker container +func (d *DockerClient) RemoveContainer(ctx context.Context, containerID string) error { + gologger.Info().Msgf("Removing container: %s", containerID[:12]) + _, err := d.client.ContainerRemove(ctx, containerID, mobyclient.ContainerRemoveOptions{Force: true}) + if err != nil { + return fmt.Errorf("failed to remove container: %w", err) + } + + gologger.Info().Msgf("Removed container: %s", containerID[:12]) + return nil +} + +// InspectContainer inspects a Docker container +func (d *DockerClient) InspectContainer(ctx context.Context, containerID string) (*ContainerInfo, error) { + info, err := d.client.ContainerInspect(ctx, containerID, mobyclient.ContainerInspectOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to inspect container: %w", err) + } + + exitCode := info.Container.State.ExitCode + + return &ContainerInfo{ + ID: info.Container.ID, + Status: string(info.Container.State.Status), + Running: info.Container.State.Running, + ExitCode: exitCode, + ImageID: info.Container.Image, // Full image ID (digest) + }, nil +} + +// GetContainerLogs gets container logs +func (d *DockerClient) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) { + options := mobyclient.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: follow, + Tail: "100", + } + + return d.client.ContainerLogs(ctx, containerID, options) +} + +// FindContainerByName finds a container by name +func (d *DockerClient) FindContainerByName(ctx context.Context, name string) (string, error) { + filter := mobyclient.Filters{} + filter.Add("name", name) + + result, err := d.client.ContainerList(ctx, mobyclient.ContainerListOptions{ + All: true, + Filters: filter, + }) + if err != nil { + return "", fmt.Errorf("failed to list containers: %w", err) + } + + if len(result.Items) == 0 { + return "", fmt.Errorf("container not found: %s", name) + } + + return result.Items[0].ID, nil +} + +// ContainerExists checks if a container exists +func (d *DockerClient) ContainerExists(ctx context.Context, name string) bool { + _, err := d.FindContainerByName(ctx, name) + return err == nil +} + +// ImageList lists Docker images +func (d *DockerClient) ImageList(ctx context.Context, options mobyclient.ImageListOptions) ([]mobyimage.Summary, error) { + result, err := d.client.ImageList(ctx, options) + if err != nil { + return nil, err + } + return result.Items, nil +} + +// FindContainersByPrefix finds all containers with names starting with the given prefix +func (d *DockerClient) FindContainersByPrefix(ctx context.Context, prefix string) ([]string, error) { + filter := mobyclient.Filters{} + filter.Add("name", prefix) + + result, err := d.client.ContainerList(ctx, mobyclient.ContainerListOptions{ + All: true, + Filters: filter, + }) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + var containerIDs []string + for _, c := range result.Items { + // Check if any of the container names start with the prefix + for _, name := range c.Names { + if len(name) > 0 && name[0] == '/' { + name = name[1:] // Remove leading slash + } + if len(name) >= len(prefix) && name[:len(prefix)] == prefix { + containerIDs = append(containerIDs, c.ID) + break + } + } + } + + return containerIDs, nil +} + +// GetImageID gets the current image ID for a given image reference +func (d *DockerClient) GetImageID(ctx context.Context, imageRef string) (string, error) { + filterArgs := mobyclient.Filters{} + filterArgs.Add("reference", imageRef) + + result, err := d.client.ImageList(ctx, mobyclient.ImageListOptions{ + Filters: filterArgs, + }) + if err != nil { + return "", fmt.Errorf("failed to list images: %w", err) + } + + if len(result.Items) == 0 { + return "", fmt.Errorf("image not found: %s", imageRef) + } + + // Return the most recent image ID (first in list, typically sorted by creation time) + return result.Items[0].ID, nil +} + +// StartContainer starts an existing container +func (d *DockerClient) StartContainer(ctx context.Context, containerID string) error { + _, err := d.client.ContainerStart(ctx, containerID, mobyclient.ContainerStartOptions{}) + return err +} + diff --git a/pkg/supervisor/docker_provider.go b/pkg/supervisor/docker_provider.go new file mode 100644 index 0000000..83db610 --- /dev/null +++ b/pkg/supervisor/docker_provider.go @@ -0,0 +1,114 @@ +package supervisor + +import ( + "context" + "fmt" + "io" +) + +// DockerProvider implements the Provider interface for Docker deployments +type DockerProvider struct { + client *DockerClient +} + +// NewDockerProvider creates a new Docker provider +func NewDockerProvider() (*DockerProvider, error) { + client, err := NewDockerClient() + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + + return &DockerProvider{client: client}, nil +} + +// Name returns the provider name +func (d *DockerProvider) Name() string { + return "docker" +} + +// IsAvailable checks if Docker is installed and running +func (d *DockerProvider) IsAvailable(ctx context.Context) bool { + if d.client == nil { + return false + } + return d.client.IsDockerRunning() +} + +// PullImage pulls a Docker image +func (d *DockerProvider) PullImage(ctx context.Context, image string) error { + return d.client.PullImage(ctx, image) +} + +// Deploy deploys a Docker container +func (d *DockerProvider) Deploy(ctx context.Context, config *DeploymentConfig) (string, error) { + // Convert DeploymentConfig to ContainerConfig + containerConfig := &ContainerConfig{ + Image: config.Image, + Name: config.Name, + Env: config.Env, + Volumes: config.Volumes, + NetworkMode: config.NetworkMode, + CapAdd: config.CapAdd, + Cmd: config.Cmd, + Restart: config.Restart, + } + + return d.client.RunContainer(ctx, containerConfig) +} + +// Stop stops a Docker container +func (d *DockerProvider) Stop(ctx context.Context, deploymentID string, timeout *int) error { + return d.client.StopContainer(ctx, deploymentID, timeout) +} + +// Remove removes a Docker container +func (d *DockerProvider) Remove(ctx context.Context, deploymentID string) error { + return d.client.RemoveContainer(ctx, deploymentID) +} + +// Start starts an existing Docker container +func (d *DockerProvider) Start(ctx context.Context, deploymentID string) error { + return d.client.StartContainer(ctx, deploymentID) +} + +// Inspect inspects a Docker container +func (d *DockerProvider) Inspect(ctx context.Context, deploymentID string) (*DeploymentInfo, error) { + info, err := d.client.InspectContainer(ctx, deploymentID) + if err != nil { + return nil, err + } + + return &DeploymentInfo{ + ID: info.ID, + Status: info.Status, + Running: info.Running, + ExitCode: info.ExitCode, + ImageID: info.ImageID, + }, nil +} + +// GetLogs gets container logs +func (d *DockerProvider) GetLogs(ctx context.Context, deploymentID string, follow bool) (io.ReadCloser, error) { + return d.client.GetContainerLogs(ctx, deploymentID, follow) +} + +// FindByName finds a container by name +func (d *DockerProvider) FindByName(ctx context.Context, name string) (string, error) { + return d.client.FindContainerByName(ctx, name) +} + +// Exists checks if a container exists +func (d *DockerProvider) Exists(ctx context.Context, name string) bool { + return d.client.ContainerExists(ctx, name) +} + +// GetImageID gets the current image ID for a given image reference +func (d *DockerProvider) GetImageID(ctx context.Context, imageRef string) (string, error) { + return d.client.GetImageID(ctx, imageRef) +} + +// FindByPrefix finds all containers with names starting with the given prefix +func (d *DockerProvider) FindByPrefix(ctx context.Context, prefix string) ([]string, error) { + return d.client.FindContainersByPrefix(ctx, prefix) +} + diff --git a/pkg/supervisor/installer.go b/pkg/supervisor/installer.go new file mode 100644 index 0000000..a7cb72f --- /dev/null +++ b/pkg/supervisor/installer.go @@ -0,0 +1,197 @@ +package supervisor + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/projectdiscovery/gologger" +) + +// Installer handles Docker installation and updates +type Installer struct { + platform string +} + +// NewInstaller creates a new installer for the current platform +func NewInstaller() *Installer { + return &Installer{ + platform: runtime.GOOS, + } +} + +// CheckDockerInstalled checks if Docker is installed +func (i *Installer) CheckDockerInstalled() (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "docker", "version") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + err := cmd.Run() + return err == nil, nil +} + +// CheckDockerRunning checks if Docker daemon is running +func (i *Installer) CheckDockerRunning() (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "docker", "info") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + err := cmd.Run() + return err == nil, nil +} + +// InstallDocker installs Docker based on the platform +func (i *Installer) InstallDocker() error { + switch i.platform { + case "linux": + return i.installDockerLinux() + case "darwin": + return i.installDockerDarwin() + case "windows": + return i.installDockerWindows() + default: + return fmt.Errorf("unsupported platform: %s", i.platform) + } +} + +// UpdateDocker updates Docker if outdated +func (i *Installer) UpdateDocker() error { + switch i.platform { + case "linux": + return i.updateDockerLinux() + case "darwin": + return i.updateDockerDarwin() + case "windows": + return i.updateDockerWindows() + default: + return fmt.Errorf("unsupported platform: %s", i.platform) + } +} + +// EnsureDocker ensures Docker is installed and running +func (i *Installer) EnsureDocker() error { + // Check if Docker is installed + installed, err := i.CheckDockerInstalled() + if err != nil { + return fmt.Errorf("failed to check Docker installation: %w", err) + } + + if !installed { + // On Linux, try to install automatically + // On macOS/Windows, provide instructions + if i.platform == "linux" { + gologger.Info().Msg("Docker is not installed. Installing Docker...") + if err := i.InstallDocker(); err != nil { + return fmt.Errorf("failed to install Docker: %w", err) + } + gologger.Info().Msg("Docker installed successfully") + } else { + // macOS or Windows - provide installation instructions + return i.getInstallationInstructions() + } + } else { + gologger.Info().Msg("Docker is already installed") + } + + // Check if Docker is running + running, err := i.CheckDockerRunning() + if err != nil { + return fmt.Errorf("failed to check Docker daemon: %w", err) + } + + if !running { + if i.platform == "linux" { + gologger.Info().Msg("Docker daemon is not running. Starting Docker...") + if err := i.StartDocker(); err != nil { + return fmt.Errorf("failed to start Docker: %w", err) + } + gologger.Info().Msg("Docker daemon started successfully") + } else { + // macOS or Windows - provide start instructions + return i.getStartInstructions() + } + } else { + gologger.Info().Msg("Docker daemon is running") + } + + // Check and update Docker if needed (Linux only) + if i.platform == "linux" { + if err := i.UpdateDocker(); err != nil { + gologger.Warning().Msgf("Failed to update Docker: %v", err) + // Don't fail if update fails, Docker might already be up to date + } + } + + return nil +} + +// StartDocker starts the Docker daemon +func (i *Installer) StartDocker() error { + switch i.platform { + case "linux": + return i.startDockerLinux() + case "darwin": + return i.startDockerDarwin() + case "windows": + return i.startDockerWindows() + default: + return fmt.Errorf("unsupported platform: %s", i.platform) + } +} + +// GetDockerVersion gets the installed Docker version +func (i *Installer) GetDockerVersion() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "docker", "version", "--format", "{{.Server.Version}}") + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +// getInstallationInstructions returns an error with installation instructions for macOS/Windows +func (i *Installer) getInstallationInstructions() error { + var url string + var platform string + + switch i.platform { + case "darwin": + url = "https://docs.docker.com/desktop/install/mac-install/" + platform = "macOS" + case "windows": + url = "https://docs.docker.com/desktop/install/windows-install/" + platform = "Windows" + default: + url = "https://docs.docker.com/get-docker/" + platform = i.platform + } + + return fmt.Errorf("docker is not installed. Docker Desktop is required for supervisor mode on %s. Please install Docker Desktop from: %s", platform, url) +} + +// getStartInstructions returns an error with start instructions for macOS/Windows +func (i *Installer) getStartInstructions() error { + var platform string + + switch i.platform { + case "darwin": + platform = "macOS" + case "windows": + platform = "Windows" + default: + platform = i.platform + } + + return fmt.Errorf("docker daemon is not running. Please start Docker Desktop manually on %s", platform) +} diff --git a/pkg/supervisor/installer_darwin.go b/pkg/supervisor/installer_darwin.go new file mode 100644 index 0000000..42ae90d --- /dev/null +++ b/pkg/supervisor/installer_darwin.go @@ -0,0 +1,64 @@ +//go:build darwin +// +build darwin + +package supervisor + +import ( + "context" + "fmt" + "os/exec" + "time" + + "github.com/projectdiscovery/gologger" +) + +// installDockerDarwin is not implemented - Docker must be installed manually on macOS +func (i *Installer) installDockerDarwin() error { + return fmt.Errorf("docker installation is not supported on macOS. Please install Docker Desktop manually") +} + + +// updateDockerDarwin is not implemented - Docker updates must be done manually on macOS +func (i *Installer) updateDockerDarwin() error { + gologger.Info().Msg("docker Desktop updates must be done manually. Please update Docker Desktop from the application.") + return nil +} + +// startDockerDarwin starts Docker Desktop on macOS +func (i *Installer) startDockerDarwin() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Check if Docker Desktop is already running + if running, _ := i.CheckDockerRunning(); running { + return nil + } + + // Try to start Docker Desktop + cmd := exec.CommandContext(ctx, "open", "-a", "Docker") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to start Docker Desktop: %w. Please start it manually from Applications", err) + } + + return i.waitForDocker(ctx) +} + +// waitForDocker waits for Docker to become available +func (i *Installer) waitForDocker(ctx context.Context) error { + timeout := time.After(2 * time.Minute) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout: + return fmt.Errorf("timeout waiting for Docker to start") + case <-ticker.C: + if running, _ := i.CheckDockerRunning(); running { + return nil + } + } + } +} diff --git a/pkg/supervisor/installer_darwin_stub.go b/pkg/supervisor/installer_darwin_stub.go new file mode 100644 index 0000000..8ecf5c3 --- /dev/null +++ b/pkg/supervisor/installer_darwin_stub.go @@ -0,0 +1,19 @@ +//go:build !darwin +// +build !darwin + +package supervisor + +import "fmt" + +func (i *Installer) installDockerDarwin() error { + return fmt.Errorf("Docker installation not supported on this platform") +} + +func (i *Installer) updateDockerDarwin() error { + return fmt.Errorf("Docker update not supported on this platform") +} + +func (i *Installer) startDockerDarwin() error { + return fmt.Errorf("Docker start not supported on this platform") +} + diff --git a/pkg/supervisor/installer_linux.go b/pkg/supervisor/installer_linux.go new file mode 100644 index 0000000..57ad69a --- /dev/null +++ b/pkg/supervisor/installer_linux.go @@ -0,0 +1,390 @@ +//go:build linux +// +build linux + +package supervisor + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" + "strings" + "time" + + "github.com/projectdiscovery/gologger" +) + +// installDockerLinux installs Docker on Linux +func (i *Installer) installDockerLinux() error { + // Detect package manager + packageManager, err := i.detectPackageManager() + if err != nil { + return fmt.Errorf("failed to detect package manager: %w", err) + } + + gologger.Info().Msgf("Detected package manager: %s", packageManager) + + // Check if running as root + if os.Geteuid() != 0 { + return fmt.Errorf("Docker installation requires root privileges. Please run with sudo") + } + + // Install Docker based on package manager + switch packageManager { + case "apt": + return i.installDockerApt() + case "yum": + return i.installDockerYum() + case "dnf": + return i.installDockerDnf() + default: + return fmt.Errorf("unsupported package manager: %s", packageManager) + } +} + +// updateDockerLinux updates Docker on Linux +func (i *Installer) updateDockerLinux() error { + packageManager, err := i.detectPackageManager() + if err != nil { + return err + } + + if os.Geteuid() != 0 { + gologger.Warning().Msg("Docker update requires root privileges. Skipping update.") + return nil + } + + switch packageManager { + case "apt": + return i.updateDockerApt() + case "yum", "dnf": + return i.updateDockerYumDnf() + default: + return nil + } +} + +// startDockerLinux starts Docker daemon on Linux +func (i *Installer) startDockerLinux() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Try systemd first + cmd := exec.CommandContext(ctx, "systemctl", "start", "docker") + if err := cmd.Run(); err == nil { + // Enable Docker to start on boot + _ = exec.Command("systemctl", "enable", "docker").Run() + return nil + } + + // Fallback to service command + cmd = exec.CommandContext(ctx, "service", "docker", "start") + return cmd.Run() +} + +// detectPackageManager detects the Linux package manager +func (i *Installer) detectPackageManager() (string, error) { + // Check for apt + if _, err := exec.LookPath("apt-get"); err == nil { + return "apt", nil + } + + // Check for yum + if _, err := exec.LookPath("yum"); err == nil { + return "yum", nil + } + + // Check for dnf + if _, err := exec.LookPath("dnf"); err == nil { + return "dnf", nil + } + + return "", fmt.Errorf("no supported package manager found (apt, yum, or dnf)") +} + +// installDockerApt installs Docker on Debian/Ubuntu using apt +func (i *Installer) installDockerApt() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // Update package index + gologger.Info().Msg("Updating package index...") + cmd := exec.CommandContext(ctx, "apt-get", "update", "-y") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to update package index: %w", err) + } + + // Install prerequisites + gologger.Info().Msg("Installing prerequisites...") + cmd = exec.CommandContext(ctx, "apt-get", "install", "-y", + "ca-certificates", + "curl", + "gnupg", + "lsb-release") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install prerequisites: %w", err) + } + + // Add Docker's official GPG key + gologger.Info().Msg("Adding Docker's GPG key...") + cmd = exec.CommandContext(ctx, "sh", "-c", "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + // Try alternative method + cmd = exec.CommandContext(ctx, "sh", "-c", "curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add Docker GPG key: %w", err) + } + } + + // Detect distribution + distro := "ubuntu" + if _, err := os.Stat("/etc/debian_version"); err == nil { + // Check if it's actually Debian + if content, err := os.ReadFile("/etc/os-release"); err == nil { + if strings.Contains(strings.ToLower(string(content)), "debian") { + distro = "debian" + } + } + } + + // Detect architecture + arch := "amd64" + if runtime.GOARCH == "arm64" { + arch = "arm64" + } + + // Add Docker repository + gologger.Info().Msgf("Adding Docker repository for %s/%s...", distro, arch) + repo := fmt.Sprintf("deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/%s $(lsb_release -cs) stable", arch, distro) + cmd = exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("echo %q >> /etc/apt/sources.list.d/docker.list", repo)) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add Docker repository: %w", err) + } + + // Update package index again + gologger.Info().Msg("Updating package index with Docker repository...") + cmd = exec.CommandContext(ctx, "apt-get", "update", "-y") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to update package index: %w", err) + } + + // Install Docker + gologger.Info().Msg("Installing Docker...") + cmd = exec.CommandContext(ctx, "apt-get", "install", "-y", + "docker-ce", + "docker-ce-cli", + "containerd.io", + "docker-buildx-plugin", + "docker-compose-plugin") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install Docker: %w", err) + } + + // Add current user to docker group + if err := i.addUserToDockerGroup(); err != nil { + gologger.Warning().Msgf("Failed to add user to docker group: %v", err) + } + + // Start Docker service + if err := i.startDockerLinux(); err != nil { + return fmt.Errorf("failed to start Docker service: %w", err) + } + + gologger.Info().Msg("Docker installed successfully") + return nil +} + +// installDockerYum installs Docker on RHEL/CentOS using yum +func (i *Installer) installDockerYum() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // Install prerequisites + gologger.Info().Msg("Installing prerequisites...") + cmd := exec.CommandContext(ctx, "yum", "install", "-y", + "yum-utils", + "device-mapper-persistent-data", + "lvm2") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install prerequisites: %w", err) + } + + // Add Docker repository + gologger.Info().Msg("Adding Docker repository...") + cmd = exec.CommandContext(ctx, "yum-config-manager", "--add-repo", "https://download.docker.com/linux/centos/docker-ce.repo") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add Docker repository: %w", err) + } + + // Install Docker + gologger.Info().Msg("Installing Docker...") + cmd = exec.CommandContext(ctx, "yum", "install", "-y", + "docker-ce", + "docker-ce-cli", + "containerd.io", + "docker-buildx-plugin", + "docker-compose-plugin") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install Docker: %w", err) + } + + // Add current user to docker group + if err := i.addUserToDockerGroup(); err != nil { + gologger.Warning().Msgf("Failed to add user to docker group: %v", err) + } + + // Start Docker service + if err := i.startDockerLinux(); err != nil { + return fmt.Errorf("failed to start Docker service: %w", err) + } + + gologger.Info().Msg("Docker installed successfully") + return nil +} + +// installDockerDnf installs Docker on Fedora using dnf +func (i *Installer) installDockerDnf() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // Install prerequisites + gologger.Info().Msg("Installing prerequisites...") + cmd := exec.CommandContext(ctx, "dnf", "install", "-y", + "dnf-plugins-core") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install prerequisites: %w", err) + } + + // Add Docker repository + gologger.Info().Msg("Adding Docker repository...") + cmd = exec.CommandContext(ctx, "dnf", "config-manager", "--add-repo", "https://download.docker.com/linux/fedora/docker-ce.repo") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add Docker repository: %w", err) + } + + // Install Docker + gologger.Info().Msg("Installing Docker...") + cmd = exec.CommandContext(ctx, "dnf", "install", "-y", + "docker-ce", + "docker-ce-cli", + "containerd.io", + "docker-buildx-plugin", + "docker-compose-plugin") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install Docker: %w", err) + } + + // Add current user to docker group + if err := i.addUserToDockerGroup(); err != nil { + gologger.Warning().Msgf("Failed to add user to docker group: %v", err) + } + + // Start Docker service + if err := i.startDockerLinux(); err != nil { + return fmt.Errorf("failed to start Docker service: %w", err) + } + + gologger.Info().Msg("Docker installed successfully") + return nil +} + +// updateDockerApt updates Docker on Debian/Ubuntu +func (i *Installer) updateDockerApt() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + gologger.Info().Msg("Updating Docker packages...") + cmd := exec.CommandContext(ctx, "apt-get", "update", "-y") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + + cmd = exec.CommandContext(ctx, "apt-get", "upgrade", "-y", + "docker-ce", + "docker-ce-cli", + "containerd.io", + "docker-buildx-plugin", + "docker-compose-plugin") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// updateDockerYumDnf updates Docker on RHEL/CentOS/Fedora +func (i *Installer) updateDockerYumDnf() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + packageManager := "yum" + if _, err := exec.LookPath("dnf"); err == nil { + packageManager = "dnf" + } + + gologger.Info().Msgf("Updating Docker packages using %s...", packageManager) + cmd := exec.CommandContext(ctx, packageManager, "update", "-y", + "docker-ce", + "docker-ce-cli", + "containerd.io", + "docker-buildx-plugin", + "docker-compose-plugin") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// addUserToDockerGroup adds the current user to the docker group +func (i *Installer) addUserToDockerGroup() error { + currentUser, err := user.Current() + if err != nil { + return err + } + + // Check if user is already in docker group + cmd := exec.Command("groups", currentUser.Username) + output, err := cmd.Output() + if err == nil && strings.Contains(string(output), "docker") { + return nil // Already in group + } + + // Add user to docker group + cmd = exec.Command("usermod", "-aG", "docker", currentUser.Username) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + + gologger.Info().Msgf("Added user %s to docker group. You may need to log out and back in for changes to take effect.", currentUser.Username) + return nil +} + diff --git a/pkg/supervisor/installer_linux_stub.go b/pkg/supervisor/installer_linux_stub.go new file mode 100644 index 0000000..f4eba03 --- /dev/null +++ b/pkg/supervisor/installer_linux_stub.go @@ -0,0 +1,19 @@ +//go:build !linux +// +build !linux + +package supervisor + +import "fmt" + +func (i *Installer) installDockerLinux() error { + return fmt.Errorf("docker installation not supported on this platform") +} + +func (i *Installer) updateDockerLinux() error { + return fmt.Errorf("docker update not supported on this platform") +} + +func (i *Installer) startDockerLinux() error { + return fmt.Errorf("docker start not supported on this platform") +} + diff --git a/pkg/supervisor/installer_windows.go b/pkg/supervisor/installer_windows.go new file mode 100644 index 0000000..b1170c4 --- /dev/null +++ b/pkg/supervisor/installer_windows.go @@ -0,0 +1,70 @@ +//go:build windows +// +build windows + +package supervisor + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/projectdiscovery/gologger" +) + +// installDockerWindows is not implemented - Docker must be installed manually on Windows +func (i *Installer) installDockerWindows() error { + return fmt.Errorf("Docker installation is not supported on Windows. Please install Docker Desktop manually") +} + +// updateDockerWindows is not implemented - Docker updates must be done manually on Windows +func (i *Installer) updateDockerWindows() error { + gologger.Info().Msg("Docker Desktop updates must be done manually. Please update Docker Desktop from the application.") + return nil +} + +// startDockerWindows starts Docker Desktop on Windows +func (i *Installer) startDockerWindows() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Check if Docker Desktop is already running + if running, _ := i.CheckDockerRunning(); running { + return nil + } + + // Try to start Docker Desktop + dockerPath := filepath.Join(os.Getenv("ProgramFiles"), "Docker", "Docker", "Docker Desktop.exe") + if _, err := os.Stat(dockerPath); os.IsNotExist(err) { + return fmt.Errorf("Docker Desktop not found at %s. Please install Docker Desktop first", dockerPath) + } + + cmd := exec.CommandContext(ctx, dockerPath) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start Docker Desktop: %w. Please start Docker Desktop manually", err) + } + + return i.waitForDockerWindows(ctx) +} + +// waitForDockerWindows waits for Docker to become available on Windows +func (i *Installer) waitForDockerWindows(ctx context.Context) error { + timeout := time.After(2 * time.Minute) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeout: + return fmt.Errorf("timeout waiting for Docker to start") + case <-ticker.C: + if running, _ := i.CheckDockerRunning(); running { + return nil + } + } + } +} diff --git a/pkg/supervisor/installer_windows_stub.go b/pkg/supervisor/installer_windows_stub.go new file mode 100644 index 0000000..8ca5744 --- /dev/null +++ b/pkg/supervisor/installer_windows_stub.go @@ -0,0 +1,19 @@ +//go:build !windows +// +build !windows + +package supervisor + +import "fmt" + +func (i *Installer) installDockerWindows() error { + return fmt.Errorf("docker installation not supported on this platform") +} + +func (i *Installer) updateDockerWindows() error { + return fmt.Errorf("docker update not supported on this platform") +} + +func (i *Installer) startDockerWindows() error { + return fmt.Errorf("docker start not supported on this platform") +} + diff --git a/pkg/supervisor/kubernetes_provider.go b/pkg/supervisor/kubernetes_provider.go new file mode 100644 index 0000000..459cba2 --- /dev/null +++ b/pkg/supervisor/kubernetes_provider.go @@ -0,0 +1,577 @@ +package supervisor + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" + + "github.com/projectdiscovery/gologger" +) + +// KubernetesProvider implements the Provider interface for Kubernetes deployments +type KubernetesProvider struct { + clientset kubernetes.Interface + namespace string +} + +// NewKubernetesProvider creates a new Kubernetes provider +func NewKubernetesProvider() (*KubernetesProvider, error) { + var config *rest.Config + var err error + + // Try in-cluster config first (when running inside Kubernetes) + config, err = rest.InClusterConfig() + if err != nil { + // Fall back to kubeconfig file + var kubeconfig string + if kubeconfigPath := os.Getenv("KUBECONFIG"); kubeconfigPath != "" { + kubeconfig = kubeconfigPath + } else { + home := homedir.HomeDir() + if home != "" { + kubeconfig = filepath.Join(home, ".kube", "config") + } + } + + if kubeconfig != "" { + config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("failed to build kubeconfig: %w", err) + } + } else { + return nil, fmt.Errorf("failed to get Kubernetes config: %w", err) + } + } + + // Create the clientset + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + // Test connection + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err = clientset.CoreV1().Namespaces().Get(ctx, "default", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to connect to Kubernetes API: %w", err) + } + + // Use default namespace (local endpoint only) + return &KubernetesProvider{ + clientset: clientset, + namespace: "pd-agent", + }, nil +} + +// Name returns the provider name +func (k *KubernetesProvider) Name() string { + return "kubernetes" +} + +// IsAvailable checks if Kubernetes is available +func (k *KubernetesProvider) IsAvailable(ctx context.Context) bool { + if k.clientset == nil { + return false + } + + // Test API server connectivity + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + _, err := k.clientset.CoreV1().Namespaces().Get(ctx, "default", metav1.GetOptions{}) + return err == nil +} + +// PullImage is a no-op for Kubernetes (kubelet handles image pulling) +func (k *KubernetesProvider) PullImage(ctx context.Context, image string) error { + // Kubernetes handles image pulling automatically via kubelet + // No action needed here + return nil +} + +// Deploy deploys a Kubernetes Deployment +func (k *KubernetesProvider) Deploy(ctx context.Context, config *DeploymentConfig) (string, error) { + // Convert DeploymentConfig to Kubernetes Deployment + deployment, secret, err := k.deploymentConfigToKubernetes(config) + if err != nil { + return "", fmt.Errorf("failed to convert config: %w", err) + } + + // Create or update Secret if needed + if secret != nil { + _, err = k.clientset.CoreV1().Secrets(k.namespace).Get(ctx, secret.Name, metav1.GetOptions{}) + if errors.IsNotFound(err) { + // Create secret + _, err = k.clientset.CoreV1().Secrets(k.namespace).Create(ctx, secret, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create secret: %w", err) + } + gologger.Info().Msgf("Created secret: %s", secret.Name) + } else if err != nil { + return "", fmt.Errorf("failed to check secret: %w", err) + } + // Secret exists, use it + } + + // Check if Deployment already exists + existing, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, deployment.Name, metav1.GetOptions{}) + if err == nil { + // Deployment exists, update it + deployment.ResourceVersion = existing.ResourceVersion + _, err = k.clientset.AppsV1().Deployments(k.namespace).Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to update deployment: %w", err) + } + gologger.Info().Msgf("Updated deployment: %s", deployment.Name) + } else if errors.IsNotFound(err) { + // Create new Deployment + _, err = k.clientset.AppsV1().Deployments(k.namespace).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create deployment: %w", err) + } + gologger.Info().Msgf("Created deployment: %s", deployment.Name) + } else { + return "", fmt.Errorf("failed to check deployment: %w", err) + } + + // Wait for Deployment to be ready + err = k.waitForDeploymentReady(ctx, deployment.Name, 60*time.Second) + if err != nil { + return "", fmt.Errorf("deployment not ready: %w", err) + } + + return deployment.Name, nil +} + +// Stop stops a Deployment by scaling it to 0 replicas +func (k *KubernetesProvider) Stop(ctx context.Context, deploymentID string, timeout *int) error { + deployment, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, deploymentID, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get deployment: %w", err) + } + + // Scale to 0 + replicas := int32(0) + deployment.Spec.Replicas = &replicas + _, err = k.clientset.AppsV1().Deployments(k.namespace).Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to scale deployment to 0: %w", err) + } + + // Wait for Pods to terminate + waitTimeout := 30 * time.Second + if timeout != nil { + waitTimeout = time.Duration(*timeout) * time.Second + } + + ctx, cancel := context.WithTimeout(ctx, waitTimeout) + defer cancel() + + err = k.waitForDeploymentReplicas(ctx, deploymentID, 0) + if err != nil { + return fmt.Errorf("failed to wait for pods to terminate: %w", err) + } + + gologger.Info().Msgf("Stopped deployment: %s", deploymentID) + return nil +} + +// Remove removes a Deployment +func (k *KubernetesProvider) Remove(ctx context.Context, deploymentID string) error { + err := k.clientset.AppsV1().Deployments(k.namespace).Delete(ctx, deploymentID, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete deployment: %w", err) + } + + // Also delete associated Secret if it exists + secretName := "pd-agent-secret" + err = k.clientset.CoreV1().Secrets(k.namespace).Delete(ctx, secretName, metav1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + // Log but don't fail - secret might be managed externally + gologger.Warning().Msgf("Failed to delete secret %s: %v", secretName, err) + } + + gologger.Info().Msgf("Removed deployment: %s", deploymentID) + return nil +} + +// Start starts a Deployment by scaling it to 1 replica +func (k *KubernetesProvider) Start(ctx context.Context, deploymentID string) error { + deployment, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, deploymentID, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get deployment: %w", err) + } + + // Scale to 1 + replicas := int32(1) + deployment.Spec.Replicas = &replicas + _, err = k.clientset.AppsV1().Deployments(k.namespace).Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to scale deployment to 1: %w", err) + } + + // Wait for Deployment to be ready + err = k.waitForDeploymentReady(ctx, deploymentID, 60*time.Second) + if err != nil { + return fmt.Errorf("deployment not ready: %w", err) + } + + gologger.Info().Msgf("Started deployment: %s", deploymentID) + return nil +} + +// Inspect inspects a Deployment and returns its status +func (k *KubernetesProvider) Inspect(ctx context.Context, deploymentID string) (*DeploymentInfo, error) { + deployment, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, deploymentID, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get deployment: %w", err) + } + + // Get Pod status + pods, err := k.clientset.CoreV1().Pods(k.namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set(deployment.Spec.Selector.MatchLabels).String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list pods: %w", err) + } + + var podStatus string + var running bool + var exitCode int + var imageID string + + if len(pods.Items) > 0 { + pod := pods.Items[0] + podStatus = string(pod.Status.Phase) + running = pod.Status.Phase == corev1.PodRunning + + // Get container status + if len(pod.Status.ContainerStatuses) > 0 { + containerStatus := pod.Status.ContainerStatuses[0] + if containerStatus.State.Terminated != nil { + exitCode = int(containerStatus.State.Terminated.ExitCode) + } + // Get image ID (digest) + if containerStatus.ImageID != "" { + imageID = containerStatus.ImageID + } + } + + // Fallback to image from spec if imageID not available + if imageID == "" && len(pod.Spec.Containers) > 0 { + imageID = pod.Spec.Containers[0].Image + } + } else { + // No pods, check deployment status + if deployment.Spec.Replicas != nil && *deployment.Spec.Replicas == 0 { + podStatus = "ScaledDown" + running = false + } else { + podStatus = "Pending" + running = false + } + // Use image from deployment spec + if len(deployment.Spec.Template.Spec.Containers) > 0 { + imageID = deployment.Spec.Template.Spec.Containers[0].Image + } + } + + return &DeploymentInfo{ + ID: deployment.Name, + Status: podStatus, + Running: running, + ExitCode: exitCode, + ImageID: imageID, + }, nil +} + +// GetLogs retrieves logs from a Pod +func (k *KubernetesProvider) GetLogs(ctx context.Context, deploymentID string, follow bool) (io.ReadCloser, error) { + deployment, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, deploymentID, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get deployment: %w", err) + } + + // Get Pods for this deployment + pods, err := k.clientset.CoreV1().Pods(k.namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set(deployment.Spec.Selector.MatchLabels).String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list pods: %w", err) + } + + if len(pods.Items) == 0 { + return nil, fmt.Errorf("no pods found for deployment %s", deploymentID) + } + + // Use the first pod + pod := pods.Items[0] + containerName := "pd-agent" + if len(pod.Spec.Containers) > 0 { + containerName = pod.Spec.Containers[0].Name + } + + // Get logs + req := k.clientset.CoreV1().Pods(k.namespace).GetLogs(pod.Name, &corev1.PodLogOptions{ + Container: containerName, + Follow: follow, + TailLines: int64Ptr(100), + }) + + return req.Stream(ctx) +} + +// FindByName finds a Deployment by name +func (k *KubernetesProvider) FindByName(ctx context.Context, name string) (string, error) { + _, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("deployment not found: %w", err) + } + return name, nil +} + +// Exists checks if a Deployment exists +func (k *KubernetesProvider) Exists(ctx context.Context, name string) bool { + _, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, name, metav1.GetOptions{}) + return err == nil +} + +// GetImageID gets the current image ID for a given image reference +func (k *KubernetesProvider) GetImageID(ctx context.Context, imageRef string) (string, error) { + // List all deployments in namespace + deployments, err := k.clientset.AppsV1().Deployments(k.namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list deployments: %w", err) + } + + // Find deployment with matching image + for _, deployment := range deployments.Items { + if len(deployment.Spec.Template.Spec.Containers) > 0 { + containerImage := deployment.Spec.Template.Spec.Containers[0].Image + // Match image reference (could be with or without tag) + if containerImage == imageRef || strings.HasPrefix(containerImage, imageRef+":") { + // Try to get actual image ID from Pod + pods, err := k.clientset.CoreV1().Pods(k.namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set(deployment.Spec.Selector.MatchLabels).String(), + }) + if err == nil && len(pods.Items) > 0 { + pod := pods.Items[0] + if len(pod.Status.ContainerStatuses) > 0 { + if pod.Status.ContainerStatuses[0].ImageID != "" { + return pod.Status.ContainerStatuses[0].ImageID, nil + } + } + } + // Fallback to image reference + return containerImage, nil + } + } + } + + // If no deployment found, return the image reference itself + // (image might not be deployed yet) + return imageRef, nil +} + +// FindByPrefix finds all Deployments with names starting with the given prefix +func (k *KubernetesProvider) FindByPrefix(ctx context.Context, prefix string) ([]string, error) { + deployments, err := k.clientset.AppsV1().Deployments(k.namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list deployments: %w", err) + } + + var matchingDeployments []string + for _, deployment := range deployments.Items { + if strings.HasPrefix(deployment.Name, prefix) { + matchingDeployments = append(matchingDeployments, deployment.Name) + } + } + + return matchingDeployments, nil +} + +// Helper functions + +// deploymentConfigToKubernetes converts DeploymentConfig to Kubernetes Deployment and Secret +func (k *KubernetesProvider) deploymentConfigToKubernetes(config *DeploymentConfig) (*appsv1.Deployment, *corev1.Secret, error) { + // Parse environment variables and extract secrets + envVars := []corev1.EnvVar{} + secretData := make(map[string][]byte) + hasSecret := false + + for _, env := range config.Env { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + continue + } + key := parts[0] + value := parts[1] + + // Store PDCP_API_KEY and PDCP_TEAM_ID in Secret + if key == "PDCP_API_KEY" || key == "PDCP_TEAM_ID" { + secretData[key] = []byte(value) + hasSecret = true + envVars = append(envVars, corev1.EnvVar{ + Name: key, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "pd-agent-secret", + }, + Key: key, + }, + }, + }) + } else { + envVars = append(envVars, corev1.EnvVar{ + Name: key, + Value: value, + }) + } + } + + // Create Secret if needed + var secret *corev1.Secret + if hasSecret { + secret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pd-agent-secret", + Namespace: k.namespace, + }, + Type: corev1.SecretTypeOpaque, + Data: secretData, + } + } + + // Parse volumes + volumeMounts := []corev1.VolumeMount{} + volumes := []corev1.Volume{} + for i, vol := range config.Volumes { + parts := strings.Split(vol, ":") + if len(parts) == 2 { + volumeName := fmt.Sprintf("volume-%d", i) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: volumeName, + MountPath: parts[1], + }) + volumes = append(volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: parts[0], + }, + }, + }) + } + } + + // Build security context with capabilities + securityContext := &corev1.SecurityContext{ + Capabilities: &corev1.Capabilities{ + Add: []corev1.Capability{}, + }, + } + for _, cap := range config.CapAdd { + securityContext.Capabilities.Add = append(securityContext.Capabilities.Add, corev1.Capability(cap)) + } + + // Create Deployment + replicas := int32(1) + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: config.Name, + Namespace: k.namespace, + Labels: map[string]string{ + "app": "pd-agent", + "managed-by": "pd-agent-supervisor", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "pd-agent", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "pd-agent", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "pd-agent", + Image: config.Image, + ImagePullPolicy: corev1.PullAlways, + Command: config.Cmd, + Env: envVars, + VolumeMounts: volumeMounts, + SecurityContext: securityContext, + }, + }, + Volumes: volumes, + HostNetwork: config.NetworkMode == "host", + RestartPolicy: corev1.RestartPolicyNever, // Supervisor manages restarts + }, + }, + }, + } + + return deployment, secret, nil +} + +// waitForDeploymentReady waits for a Deployment to be ready +func (k *KubernetesProvider) waitForDeploymentReady(ctx context.Context, name string, timeout time.Duration) error { + return wait.PollUntilContextTimeout(ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + deployment, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + + // Check if deployment is ready + if deployment.Status.ReadyReplicas >= 1 && deployment.Status.Replicas == 1 { + return true, nil + } + + return false, nil + }) +} + +// waitForDeploymentReplicas waits for a Deployment to reach the desired number of replicas +func (k *KubernetesProvider) waitForDeploymentReplicas(ctx context.Context, name string, desiredReplicas int32) error { + return wait.PollUntilContextTimeout(ctx, 2*time.Second, 60*time.Second, true, func(ctx context.Context) (bool, error) { + deployment, err := k.clientset.AppsV1().Deployments(k.namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + + if deployment.Status.Replicas == desiredReplicas { + return true, nil + } + + return false, nil + }) +} + +// int64Ptr returns a pointer to an int64 +func int64Ptr(i int64) *int64 { + return &i +} + diff --git a/pkg/supervisor/monitor.go b/pkg/supervisor/monitor.go new file mode 100644 index 0000000..906060d --- /dev/null +++ b/pkg/supervisor/monitor.go @@ -0,0 +1,118 @@ +package supervisor + +import ( + "context" + "fmt" + "time" + + "github.com/projectdiscovery/gologger" +) + +// Monitor monitors deployment health and manages restarts +type Monitor struct { + provider Provider + deploymentID string + restartCount int + maxRestarts int + checkInterval time.Duration +} + +// HealthStatus represents deployment health status +type HealthStatus struct { + Running bool + ExitCode int + Status string +} + +// NewMonitor creates a new deployment monitor +func NewMonitor(provider Provider, maxRestarts int) *Monitor { + return &Monitor{ + provider: provider, + maxRestarts: maxRestarts, + checkInterval: 30 * time.Second, + } +} + +// StartMonitoring starts monitoring a deployment +func (m *Monitor) StartMonitoring(ctx context.Context, deploymentID string) { + m.deploymentID = deploymentID + + ticker := time.NewTicker(m.checkInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + health, err := m.CheckHealth(ctx, deploymentID) + if err != nil { + gologger.Warning().Msgf("Failed to check deployment health: %v", err) + continue + } + + if !health.Running && health.ExitCode != 0 { + gologger.Warning().Msgf("Deployment exited with code %d, status: %s", health.ExitCode, health.Status) + + if m.restartCount < m.maxRestarts { + m.restartCount++ + gologger.Info().Msgf("Restarting deployment (attempt %d/%d)", m.restartCount, m.maxRestarts) + + if err := m.RestartDeployment(ctx, deploymentID); err != nil { + gologger.Error().Msgf("Failed to restart deployment: %v", err) + } + } else { + gologger.Error().Msgf("Max restart attempts (%d) reached, stopping monitoring", m.maxRestarts) + return + } + } + } + } +} + +// CheckHealth checks deployment health +func (m *Monitor) CheckHealth(ctx context.Context, deploymentID string) (*HealthStatus, error) { + info, err := m.provider.Inspect(ctx, deploymentID) + if err != nil { + return nil, fmt.Errorf("failed to inspect deployment: %w", err) + } + + return &HealthStatus{ + Running: info.Running, + ExitCode: info.ExitCode, + Status: info.Status, + }, nil +} + +// RestartDeployment restarts a deployment +func (m *Monitor) RestartDeployment(ctx context.Context, deploymentID string) error { + // Stop deployment + timeout := 10 + if err := m.provider.Stop(ctx, deploymentID, &timeout); err != nil { + return fmt.Errorf("failed to stop deployment: %w", err) + } + + // Remove deployment + if err := m.provider.Remove(ctx, deploymentID); err != nil { + return fmt.Errorf("failed to remove deployment: %w", err) + } + + // Note: Deployment will be recreated by supervisor's Run loop + return nil +} + +// RestartContainer restarts a container (deprecated, use RestartDeployment) +func (m *Monitor) RestartContainer(ctx context.Context, containerID string) error { + return m.RestartDeployment(ctx, containerID) +} + +// GetRestartCount returns the current restart count +func (m *Monitor) GetRestartCount() int { + return m.restartCount +} + +// ResetRestartCount resets the restart count +func (m *Monitor) ResetRestartCount() { + m.restartCount = 0 +} + diff --git a/pkg/supervisor/options.go b/pkg/supervisor/options.go new file mode 100644 index 0000000..44fa040 --- /dev/null +++ b/pkg/supervisor/options.go @@ -0,0 +1,10 @@ +package supervisor + +// ConvertOptions converts main.Options to supervisor.AgentOptions +// This is a helper function to bridge between the main package and supervisor package +func ConvertOptions(options interface{}) *AgentOptions { + // Use reflection or type assertion - for now we'll use a function that accepts the needed fields + // This avoids importing the main package + return nil // Will be implemented via a callback pattern +} + diff --git a/pkg/supervisor/provider.go b/pkg/supervisor/provider.go new file mode 100644 index 0000000..6693355 --- /dev/null +++ b/pkg/supervisor/provider.go @@ -0,0 +1,72 @@ +package supervisor + +import ( + "context" + "io" +) + +// Provider defines the interface for deployment providers (Docker, Kubernetes, ECS, etc.) +type Provider interface { + // Name returns the provider name (e.g., "docker", "kubernetes", "ecs") + Name() string + + // IsAvailable checks if the provider is available/installed + IsAvailable(ctx context.Context) bool + + // PullImage pulls the latest image for the deployment + PullImage(ctx context.Context, image string) error + + // Deploy deploys/runs a container/workload with the given configuration + // Returns the deployment ID (container ID, pod name, task ARN, etc.) + Deploy(ctx context.Context, config *DeploymentConfig) (string, error) + + // Stop stops a running deployment + Stop(ctx context.Context, deploymentID string, timeout *int) error + + // Remove removes a deployment + Remove(ctx context.Context, deploymentID string) error + + // Start starts an existing deployment + Start(ctx context.Context, deploymentID string) error + + // Inspect gets information about a deployment + Inspect(ctx context.Context, deploymentID string) (*DeploymentInfo, error) + + // GetLogs retrieves logs from a deployment + GetLogs(ctx context.Context, deploymentID string, follow bool) (io.ReadCloser, error) + + // FindByName finds a deployment by name + FindByName(ctx context.Context, name string) (string, error) + + // Exists checks if a deployment exists + Exists(ctx context.Context, name string) bool + + // GetImageID gets the current image ID for a given image reference + GetImageID(ctx context.Context, imageRef string) (string, error) + + // FindByPrefix finds all deployments with names starting with the given prefix + FindByPrefix(ctx context.Context, prefix string) ([]string, error) +} + +// DeploymentInfo represents deployment inspection information +type DeploymentInfo struct { + ID string + Status string + Running bool + ExitCode int + ImageID string // Image ID the deployment is using +} + +// DeploymentConfig represents a generic deployment configuration +// Provider implementations should convert this to their specific format +type DeploymentConfig struct { + Image string + Name string + Env []string + Volumes []string + NetworkMode string + CapAdd []string + Cmd []string + Restart string +} + diff --git a/pkg/supervisor/supervisor.go b/pkg/supervisor/supervisor.go new file mode 100644 index 0000000..edb1680 --- /dev/null +++ b/pkg/supervisor/supervisor.go @@ -0,0 +1,336 @@ +package supervisor + +import ( + "context" + "fmt" + "os" + "os/signal" + "runtime" + "syscall" + "time" + + "github.com/projectdiscovery/gologger" +) + +// Supervisor manages the deployment running pd-agent +type Supervisor struct { + config *AgentOptions + provider Provider + updater *Updater + binaryUpdater *BinaryUpdater + monitor *Monitor + deploymentID string + deploymentConfig *DeploymentConfig +} + +// NewSupervisor creates a new supervisor instance (defaults to Docker provider) +func NewSupervisor(config *AgentOptions) (*Supervisor, error) { + return NewSupervisorWithProvider(config, "docker") +} + +// NewSupervisorWithProvider creates a new supervisor instance with specified provider +func NewSupervisorWithProvider(config *AgentOptions, providerType string) (*Supervisor, error) { + // Initialize provider + var provider Provider + var err error + + switch providerType { + case "kubernetes": + provider, err = NewKubernetesProvider() + if err != nil { + return nil, fmt.Errorf("failed to initialize Kubernetes provider: %w", err) + } + case "docker": + fallthrough + default: + provider, err = NewDockerProvider() + if err != nil { + return nil, fmt.Errorf("failed to initialize Docker provider: %w", err) + } + } + + // Build deployment configuration + deploymentConfig := BuildDeploymentConfig(config, config.AgentID) + + // Initialize updater + updateInterval := 24 * time.Hour + updater := NewUpdater(provider, deploymentConfig.Image, updateInterval) + + // Initialize monitor + maxRestarts := 5 + monitor := NewMonitor(provider, maxRestarts) + + // Initialize binary updater + binaryUpdater := NewBinaryUpdater("projectdiscovery", "pd-agent") + + return &Supervisor{ + config: config, + provider: provider, + updater: updater, + binaryUpdater: binaryUpdater, + monitor: monitor, + deploymentConfig: deploymentConfig, + }, nil +} + +// Run starts the supervisor and manages the container lifecycle +func (s *Supervisor) Run(ctx context.Context) error { + gologger.Info().Msg("Starting pd-agent supervisor") + + // Ensure provider is available + if !s.provider.IsAvailable(ctx) { + // For Docker, check installation + if s.provider.Name() == "docker" { + installer := NewInstaller() + if err := installer.EnsureDocker(); err != nil { + return fmt.Errorf("failed to ensure Docker is installed and running: %w", err) + } + } else { + return fmt.Errorf("deployment provider %s is not available", s.provider.Name()) + } + } + + // Start Docker image update loop in background + // The update loop will handle the initial image pull + updateCtx, updateCancel := context.WithCancel(context.Background()) + defer updateCancel() + + go s.updater.StartUpdateLoop(updateCtx, func() error { + // Restart container with new image + return s.restartContainer(ctx) + }) + + // Start binary update loop in background + binaryUpdateCtx, binaryUpdateCancel := context.WithCancel(context.Background()) + defer binaryUpdateCancel() + + go s.binaryUpdater.StartUpdateLoop(binaryUpdateCtx, nil) + + // Main loop: ensure container is running + for { + select { + case <-ctx.Done(): + gologger.Info().Msg("Shutting down supervisor") + // Use a background context with timeout for shutdown to avoid context canceled error + stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer stopCancel() + return s.Stop(stopCtx) + default: + // Check if deployment exists and is running + exists := s.provider.Exists(ctx, s.deploymentConfig.Name) + + if !exists || s.deploymentID == "" { + // Deployment doesn't exist, create and start it + if err := s.Start(ctx); err != nil { + gologger.Error().Msgf("Failed to start deployment: %v", err) + time.Sleep(10 * time.Second) // Wait before retry + continue + } + } else { + // Check deployment health + info, err := s.provider.Inspect(ctx, s.deploymentID) + if err != nil { + gologger.Warning().Msgf("Failed to inspect deployment: %v", err) + s.deploymentID = "" // Reset to trigger restart + continue + } + + if !info.Running { + gologger.Warning().Msgf("Deployment is not running, status: %s", info.Status) + s.deploymentID = "" // Reset to trigger restart + continue + } + } + + // Sleep before next check + time.Sleep(30 * time.Second) + } + } +} + +// Start starts the deployment +func (s *Supervisor) Start(ctx context.Context) error { + // Get current image ID + currentImageID, err := s.provider.GetImageID(ctx, s.deploymentConfig.Image) + if err != nil { + gologger.Warning().Msgf("Failed to get current image ID: %v, proceeding with cleanup", err) + currentImageID = "" + } + + // Check existing deployments and compare image versions + existingDeployments, err := s.provider.FindByPrefix(ctx, "pd-agent-") + if err == nil && len(existingDeployments) > 0 { + // Check if any deployment is using an outdated image + hasOutdatedDeployments := false + if currentImageID != "" { + for _, deploymentID := range existingDeployments { + deploymentInfo, err := s.provider.Inspect(ctx, deploymentID) + if err == nil && deploymentInfo.ImageID != currentImageID { + hasOutdatedDeployments = true + gologger.Info().Msgf("Found deployment %s using outdated image (current: %s, deployment: %s)", + deploymentID[:12], currentImageID[:12], deploymentInfo.ImageID[:12]) + break + } + } + } + + // Only clean up if image was updated (outdated deployments found) or if we couldn't check image version + if hasOutdatedDeployments || currentImageID == "" { + gologger.Info().Msgf("Found %d existing pd-agent deployment(s), cleaning up...", len(existingDeployments)) + for _, deploymentID := range existingDeployments { + // Stop deployment if running + _ = s.provider.Stop(ctx, deploymentID, nil) + // Remove deployment + _ = s.provider.Remove(ctx, deploymentID) + } + } else { + // Image is up to date, check if we have a running deployment with the current image + for _, deploymentID := range existingDeployments { + deploymentInfo, err := s.provider.Inspect(ctx, deploymentID) + if err == nil && deploymentInfo.ImageID == currentImageID { + if deploymentInfo.Running { + // Deployment is running with current image, nothing to do + // Only log if deploymentID changed (first time we find it) + if s.deploymentID != deploymentID { + gologger.Info().Msgf("Deployment %s is already running with current image", deploymentID[:12]) + } + s.deploymentID = deploymentID + s.monitor.ResetRestartCount() + return nil + } else { + // Deployment exists but not running, restart it + gologger.Info().Msgf("Deployment %s exists with current image but is not running, restarting...", deploymentID[:12]) + s.deploymentID = deploymentID + // Start the existing deployment + if err := s.provider.Start(ctx, deploymentID); err != nil { + gologger.Warning().Msgf("Failed to start existing deployment: %v, will create new one", err) + // Fall through to create new deployment + } else { + s.monitor.ResetRestartCount() + gologger.Info().Msgf("Restarted deployment: %s", deploymentID[:12]) + return nil + } + } + } + } + } + } + + // Create and start deployment + deploymentID, err := s.provider.Deploy(ctx, s.deploymentConfig) + if err != nil { + return fmt.Errorf("failed to deploy: %w", err) + } + + s.deploymentID = deploymentID + s.monitor.ResetRestartCount() + + gologger.Info().Msgf("Deployment started successfully: %s", deploymentID[:12]) + return nil +} + +// Stop stops the deployment +func (s *Supervisor) Stop(ctx context.Context) error { + if s.deploymentID == "" { + return nil + } + + gologger.Info().Msg("Stopping deployment") + timeout := 30 + if err := s.provider.Stop(ctx, s.deploymentID, &timeout); err != nil { + // If context was canceled, try with a background context + if ctx.Err() == context.Canceled { + stopCtx, stopCancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer stopCancel() + if err := s.provider.Stop(stopCtx, s.deploymentID, &timeout); err != nil { + return fmt.Errorf("failed to stop deployment: %w", err) + } + return nil + } + return fmt.Errorf("failed to stop deployment: %w", err) + } + + return nil +} + +// Restart restarts the container +func (s *Supervisor) Restart(ctx context.Context) error { + if err := s.Stop(ctx); err != nil { + return err + } + + time.Sleep(2 * time.Second) + + return s.Start(ctx) +} + +// restartContainer restarts the deployment (used by updater) +func (s *Supervisor) restartContainer(ctx context.Context) error { + gologger.Info().Msg("Restarting deployment with updated image") + return s.Restart(ctx) +} + +// Update triggers a manual update +func (s *Supervisor) Update(ctx context.Context) error { + wasUpdated, err := s.updater.Update(ctx) + if err != nil { + return err + } + + // Only restart if image was actually updated + if wasUpdated { + return s.restartContainer(ctx) + } + + return nil +} + +// GetDeploymentID returns the current deployment ID +func (s *Supervisor) GetDeploymentID() string { + return s.deploymentID +} + +// GetContainerID returns the current container ID (deprecated, use GetDeploymentID) +func (s *Supervisor) GetContainerID() string { + return s.deploymentID +} + +// SetupSignalHandlers sets up signal handlers for graceful shutdown and manual updates +func (s *Supervisor) SetupSignalHandlers(ctx context.Context) context.Context { + sigChan := make(chan os.Signal, 1) + // Note: SIGUSR1 is Unix-only signal + signals := []os.Signal{os.Interrupt, syscall.SIGTERM} + if runtime.GOOS != "windows" { + signals = append(signals, syscall.SIGUSR1) + } + signal.Notify(sigChan, signals...) + + ctx, cancel := context.WithCancel(ctx) + + go func() { + for sig := range sigChan { + // Handle Unix-specific signals + if runtime.GOOS != "windows" { + if sig == syscall.SIGUSR1 { + // Manual image update trigger (Unix only) + gologger.Info().Msgf("Manual %s image update triggered", s.provider.Name()) + if err := s.Update(ctx); err != nil { + gologger.Error().Msgf("Manual update failed: %v", err) + } + continue + } + } + + // Handle shutdown signals + switch sig { + case os.Interrupt, syscall.SIGTERM: + // Graceful shutdown + gologger.Info().Msg("Shutdown signal received") + cancel() + return + } + } + }() + + return ctx +} diff --git a/pkg/supervisor/updater.go b/pkg/supervisor/updater.go new file mode 100644 index 0000000..55462d7 --- /dev/null +++ b/pkg/supervisor/updater.go @@ -0,0 +1,149 @@ +package supervisor + +import ( + "context" + "fmt" + "time" + + "github.com/projectdiscovery/gologger" +) + +// Updater handles deployment image updates +type Updater struct { + provider Provider + image string + checkInterval time.Duration + lastCheck time.Time + lastImageID string // Track last known image ID to detect changes +} + +// NewUpdater creates a new updater +func NewUpdater(provider Provider, image string, checkInterval time.Duration) *Updater { + return &Updater{ + provider: provider, + image: image, + checkInterval: checkInterval, + } +} + +// CheckForUpdates checks if a new image version is available +func (u *Updater) CheckForUpdates(ctx context.Context) (bool, error) { + u.lastCheck = time.Now() + + // Get current image ID before pulling + currentImageID, err := u.provider.GetImageID(ctx, u.image) + if err != nil { + // Image doesn't exist locally, we need to pull it + return true, nil + } + + // Store current image ID for comparison + oldImageID := u.lastImageID + u.lastImageID = currentImageID + + // If this is the first check, we don't know if there's an update + // But we should still pull to ensure we have the latest + if oldImageID == "" { + return true, nil + } + + // Compare with last known image ID + // If they're the same, no update needed + if currentImageID == oldImageID { + return false, nil + } + + // Image ID changed, update available + return true, nil +} + +// Update pulls the latest image and returns whether the image was actually updated +func (u *Updater) Update(ctx context.Context) (bool, error) { + // Get image ID before pulling + oldImageID, err := u.provider.GetImageID(ctx, u.image) + if err != nil { + // Image doesn't exist, we need to pull it + oldImageID = "" + } + + // If we have a last known image ID and it matches, skip the pull + if u.lastImageID != "" && oldImageID != "" && u.lastImageID == oldImageID { + // Image hasn't changed since last check, no need to pull + return false, nil + } + + gologger.Info().Msgf("Checking for updates for image: %s", u.image) + + // Pull the latest image + if err := u.provider.PullImage(ctx, u.image); err != nil { + return false, fmt.Errorf("failed to pull image: %w", err) + } + + // Get image ID after pulling + newImageID, err := u.provider.GetImageID(ctx, u.image) + if err != nil { + return false, fmt.Errorf("failed to get image ID after pull: %w", err) + } + + // Check if image actually changed + wasUpdated := false + if oldImageID != "" && oldImageID != newImageID { + wasUpdated = true + gologger.Info().Msgf("Image updated: %s (old: %s, new: %s)", u.image, oldImageID[:12], newImageID[:12]) + } else if oldImageID == "" { + // First time pulling + gologger.Info().Msgf("Successfully pulled image: %s (ID: %s)", u.image, newImageID[:12]) + } else { + // Image is up to date + gologger.Verbose().Msgf("Image %s is already up to date (ID: %s)", u.image, newImageID[:12]) + } + + // Update last known image ID + u.lastImageID = newImageID + + return wasUpdated, nil +} + +// StartUpdateLoop starts the periodic update check loop +func (u *Updater) StartUpdateLoop(ctx context.Context, updateCallback func() error) { + // Initial check - always pull on startup to ensure we have the latest + // This happens once, not on a timer + wasUpdated, err := u.Update(ctx) + if err != nil { + gologger.Warning().Msgf("Failed to update on startup: %v", err) + } else if wasUpdated && updateCallback != nil { + // Only restart if image was actually updated + if err := updateCallback(); err != nil { + gologger.Warning().Msgf("Update callback failed: %v", err) + } + } + + // Now start the periodic check loop (24 hours) + ticker := time.NewTicker(u.checkInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + wasUpdated, err := u.Update(ctx) + if err != nil { + gologger.Warning().Msgf("Failed to update: %v", err) + continue + } + + // Only trigger restart if image was actually updated + if wasUpdated && updateCallback != nil { + if err := updateCallback(); err != nil { + gologger.Warning().Msgf("Update callback failed: %v", err) + } + } + } + } +} + +// GetLastCheck returns the last update check time +func (u *Updater) GetLastCheck() time.Time { + return u.lastCheck +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..dfd2787 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,12 @@ +package version + +// Version information set at build time via ldflags +var ( + // Version is the semantic version of the build + Version = "v0.1.2" +) + +// GetVersion returns the version string +func GetVersion() string { + return Version +} From e464ff8cf2760c6be2eeb6f6857a5fe1fd9c7729 Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Mon, 22 Dec 2025 17:53:53 +0400 Subject: [PATCH 2/3] adding readme --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 47600a2..36e0744 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ PD AgentInstallationQuick Start • + Supervisor ModeSystem InstallationJoin Discord @@ -65,6 +66,38 @@ kubectl get pods -n pd-agent -l app=pd-agent The agent automatically discovers Kubernetes cluster subnets (nodes, pods, services) for scanning. See [examples/README.md](examples/README.md) for detailed instructions and customization options. +### Supervisor Mode + +Supervisor mode allows pd-agent to manage its own deployment in Docker or Kubernetes, automatically handling updates, restarts, and lifecycle management. + +#### Prerequisites + +- **macOS/Windows**: Docker Desktop must be installed and running + - On Windows, Docker Desktop has automatic integration with WSL2 +- **Linux**: Docker must be installed and running + +#### Usage + +Run pd-agent in supervisor mode with Docker (default): + +```bash +pd-agent -supervisor-mode docker +``` + +Or use Kubernetes: + +```bash +pd-agent -supervisor-mode kubernetes +``` + +The supervisor will: +- Automatically pull and deploy the latest pd-agent Docker image +- Monitor the deployment and restart if it crashes +- Handle image updates automatically +- Manage the container/pod lifecycle + +**Note**: Supervisor mode requires Docker or Kubernetes to be available and properly configured. The supervisor runs the agent in a container/pod, so all agent configuration (environment variables, flags) should be passed as normal. + ### Network Discovery The agent automatically discovers local network subnets and reports them to the platform: From de29a1a44939a7eca2dbd2a1e55721ba5857a2ce Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Mon, 22 Dec 2025 18:07:20 +0400 Subject: [PATCH 3/3] fixing lib call --- pkg/supervisor/supervisor.go | 17 +++------------ pkg/supervisor/supervisor_unix.go | 31 ++++++++++++++++++++++++++++ pkg/supervisor/supervisor_windows.go | 22 ++++++++++++++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 pkg/supervisor/supervisor_unix.go create mode 100644 pkg/supervisor/supervisor_windows.go diff --git a/pkg/supervisor/supervisor.go b/pkg/supervisor/supervisor.go index edb1680..f9a5757 100644 --- a/pkg/supervisor/supervisor.go +++ b/pkg/supervisor/supervisor.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "os/signal" - "runtime" "syscall" "time" @@ -298,11 +297,8 @@ func (s *Supervisor) GetContainerID() string { // SetupSignalHandlers sets up signal handlers for graceful shutdown and manual updates func (s *Supervisor) SetupSignalHandlers(ctx context.Context) context.Context { sigChan := make(chan os.Signal, 1) - // Note: SIGUSR1 is Unix-only signal signals := []os.Signal{os.Interrupt, syscall.SIGTERM} - if runtime.GOOS != "windows" { - signals = append(signals, syscall.SIGUSR1) - } + signals = appendUnixSignals(signals) signal.Notify(sigChan, signals...) ctx, cancel := context.WithCancel(ctx) @@ -310,15 +306,8 @@ func (s *Supervisor) SetupSignalHandlers(ctx context.Context) context.Context { go func() { for sig := range sigChan { // Handle Unix-specific signals - if runtime.GOOS != "windows" { - if sig == syscall.SIGUSR1 { - // Manual image update trigger (Unix only) - gologger.Info().Msgf("Manual %s image update triggered", s.provider.Name()) - if err := s.Update(ctx); err != nil { - gologger.Error().Msgf("Manual update failed: %v", err) - } - continue - } + if handleUnixSignal(s, ctx, sig) { + continue } // Handle shutdown signals diff --git a/pkg/supervisor/supervisor_unix.go b/pkg/supervisor/supervisor_unix.go new file mode 100644 index 0000000..9f5e262 --- /dev/null +++ b/pkg/supervisor/supervisor_unix.go @@ -0,0 +1,31 @@ +//go:build !windows +// +build !windows + +package supervisor + +import ( + "context" + "os" + "syscall" + + "github.com/projectdiscovery/gologger" +) + +// appendUnixSignals appends Unix-specific signals to the signals slice +func appendUnixSignals(signals []os.Signal) []os.Signal { + return append(signals, syscall.SIGUSR1) +} + +// handleUnixSignal handles Unix-specific signals +func handleUnixSignal(s *Supervisor, ctx context.Context, sig os.Signal) bool { + if sig == syscall.SIGUSR1 { + // Manual image update trigger (Unix only) + gologger.Info().Msgf("Manual %s image update triggered", s.provider.Name()) + if err := s.Update(ctx); err != nil { + gologger.Error().Msgf("Manual update failed: %v", err) + } + return true + } + return false +} + diff --git a/pkg/supervisor/supervisor_windows.go b/pkg/supervisor/supervisor_windows.go new file mode 100644 index 0000000..b76703a --- /dev/null +++ b/pkg/supervisor/supervisor_windows.go @@ -0,0 +1,22 @@ +//go:build windows +// +build windows + +package supervisor + +import ( + "context" + "os" +) + +// appendUnixSignals appends Unix-specific signals to the signals slice +// On Windows, this is a no-op +func appendUnixSignals(signals []os.Signal) []os.Signal { + return signals +} + +// handleUnixSignal handles Unix-specific signals +// On Windows, this always returns false +func handleUnixSignal(s *Supervisor, ctx context.Context, sig os.Signal) bool { + return false +} +