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/README.md b/README.md
index 47600a2..36e0744 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
PD Agent •
Installation •
Quick Start •
+ Supervisor Mode •
System Installation •
Join 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:
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..f9a5757
--- /dev/null
+++ b/pkg/supervisor/supervisor.go
@@ -0,0 +1,325 @@
+package supervisor
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/signal"
+ "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)
+ signals := []os.Signal{os.Interrupt, syscall.SIGTERM}
+ signals = appendUnixSignals(signals)
+ signal.Notify(sigChan, signals...)
+
+ ctx, cancel := context.WithCancel(ctx)
+
+ go func() {
+ for sig := range sigChan {
+ // Handle Unix-specific signals
+ if handleUnixSignal(s, ctx, sig) {
+ 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/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
+}
+
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
+}