guest@6admin.io:~$

PwnMachine: the self-hosted bug bounty workstation

When you do bug bounty, you need an environment: a DNS you control (for callbacks), a reverse proxy (to expose tools) and a web interface to orchestrate. All on a machine you can reinstall in ten minutes. PwnMachine is that setup, packaged in Docker Compose.

The concept

PwnMachine is a self-hosting solution based on Docker aiming to provide an easy-to-use pwning station for bug hunters. The basic install includes a web interface, a DNS server and a reverse proxy. One docker-compose up --build -d and it stands up.

The components:

  • Traefik : the reverse proxy that routes to each tool and manages Let’s Encrypt certificates
  • PowerDNS : the DNS server for callbacks (OOB interactions, DNS rebinding)
  • The web interface : a manager orchestrating the pentest tools
  • Redis : the manager’s cache
  • logbeat : the log shipper

The setup

git clone https://github.com/yeswehack/pwn-machine.git
cd pwn-machine/
echo 'LETS_ENCRYPT_EMAIL="your_email@domain.com"' > .env
docker-compose up --build -d
Starting pm_powerdns-db_1   ... done
Starting pm_redis_1         ... done
Starting pm_powerdns_1      ... done
Starting pm_logbeat_1       ... done
Recreating traefik          ... done
Recreating pm_manager_1     ... done

After that, http://your_ip shows the web interface. DNS points to the PowerDNS server and callbacks (Burp Collaborator, DNS rebinding, XSS callbacks) arrive on the server you control.

The three versions

The project went through three iterations:

  • v1 (July 2020) : a Python client, pm. pip install pwn-machine, then pm setup and pm service start: it drove a remote machine created with docker-machine and deployed DNS, the reverse proxy and the webserver on it.
  • v2 (2021) : the pure-Docker rewrite. Nothing left to install client-side, just Docker: docker-compose up --build -d. The dockers/ are split per tool (manager, PowerDNS, Traefik, logbeat) and a web interface drives images and containers.
  • the internal YesWeHack version (2022) : the production deployment I built at YesWeHack. A dedicated VM, Docker in rootless mode, a dedicated compose file (docker-compose-h2h.yml) and Salt provisioning for the environment and log rotation.

The big gap is between v1 and v2: the Python client and docker-machine are gone, only Docker remains.

The internal production deployment

The internal version pushes the setup further than the public repository: images are built by CI and pulled from a private registry, the Docker daemon runs rootless and the whole thing is provisioned by Salt. Here is the real surface of that deployment, stripped of the internal hosts.

The code

Three tweaks to the v2 code.

The manager registered itself in Traefik at startup, creating a service and a router in the Redis provider. In production the routing is declared in the compose file, the manager no longer publishes itself:

-        await traefik_redis.create_service(
-            "pm-manager-service",
-            "http",
-            "loadBalancer",
-            {
-                "servers": [{"url": "http://manager:5000/"}],
-            },
-        )
-        await traefik_redis.create_router(
-            "http",
-            {
-                "entrypoints": ["http"],
-                "name": "pm-manager-router",
-                "service": "pm-manager-service@redis",
-                "rule": "PathPrefix(`/`)",
-            },
-        )
         await redis_client.set(MANAGER_ROUTER_INSTALLED_KEY, "True")

PowerDNS writes its API key into its configuration at boot, instead of freezing it in the image. It refuses to start when the variable is missing:

-echo "Starting pdns_server"
-/usr/sbin/pdns_server
+if [[ -n "$PDNS_API_KEY" ]];then
+	echo "Starting pdns_server"
+	echo "api-key=$PDNS_API_KEY" >> /etc/powerdns/pdns.conf
+	/usr/sbin/pdns_server 
+else
+	echo "error :\$PDNS_API_KEY missing from environment variable"
+fi

rotatedb, a rotation script for the SQLite log database: compressed dump, purge of entries older than one day, vacuum, then removal of the backups older than thirty days:

#!/usr/bin/env bash
# DB rotation 
vDataDir="/home/docker/.local/share/docker/volumes/all-logs/_data"
vBackupFilename="logs.$(date +"%Y%m%d-%H%M%S").sql.bz2"

# Get path (unused here) and docker socket from docker user's ~/.profile
source /home/docker/.profile

# Stop logbeat container
/home/docker/bin/docker-compose -f /home/docker/pwn-machine/docker-compose-h2h.yml stop logbeat

# Dump sqlite db
/usr/bin/sqlite3 -list "$vDataDir/logs.db" ".dump" | /usr/bin/bzip2 > "$vDataDir/$vBackupFilename"
/usr/bin/chmod 0600 "$vDataDir/$vBackupFilename"
/usr/bin/chown docker:docker "$vDataDir/$vBackupFilename"	#If script is copied to /etc/cron.daily instead of docker user crontab.

# Remove all entries older than 1 day
/usr/bin/sqlite3 -list "$vDataDir/logs.db" "delete from docker_logs where date < strftime('%s','now','-1 days');"
/usr/bin/sqlite3 -list "$vDataDir/logs.db" "delete from powerdns_logs where date < strftime('%s','now','-1 days');"
/usr/bin/sqlite3 -list "$vDataDir/logs.db" "delete from traefik_logs where date < strftime('%s','now','-1 days');"

# Vacuum db
/usr/bin/sqlite3 -list "$vDataDir/logs.db" "vacuum;"

# Remove all backup files older than 30 days
/usr/bin/find "$vDataDir" -type f -name "*.sql.bz2" -mtime +30 -delete

# Start logbeat container
/home/docker/bin/docker-compose -f /home/docker/pwn-machine/docker-compose-h2h.yml start logbeat

The images

The Dockerfiles move to pinned bases and batch the upgrades, for more reproducible images.

The manager builds the front then the API in a single RUN and cleans the apt lists:

-#FROM node:15.10.0 as build-front
 FROM node:16.14.0-bullseye-slim as build-front
 WORKDIR /app
-COPY front/package*.json ./
-RUN yarn install
 COPY front/ ./
-RUN yarn build
+RUN yarn install && yarn build
 
 FROM python:3.9
 WORKDIR /usr/src/app
-RUN apt update && apt install -y docker.io
-#RUN pip install --upgrade pip poetry
-RUN pip install poetry
-COPY back/pyproject.toml ./
-RUN poetry install
-COPY back/ ./
 COPY --from=build-front /app/dist/spa/ static/
+COPY back/ ./
+
+RUN apt-get update && apt-get upgrade -y && apt-get -y install docker.io && \
+    pip install poetry && poetry update && poetry install && \
+    rm -rf /tmp/* /var/tmp/* /var/lib/apt/lists/*
+
 EXPOSE 5000/tcp
 CMD poetry run uvicorn --host 0.0.0.0 --port 5000 --log-level warning app:app

The logbeat installs sqlite without cache:

 FROM node:16-alpine as common
-
 WORKDIR /usr/src/app
 COPY package.json yarn.lock ./
-RUN yarn install
+RUN yarn install && yarn upgrade  
 
 FROM common as builder
 ADD . ./
 RUN yarn build
 
 FROM common
-RUN apk update && apk add sqlite
-COPY  --from=builder /usr/src/app/dist/ ./
+COPY --from=builder /usr/src/app/dist/ ./
+RUN apk update && apk upgrade --no-cache && apk add --no-cache sqlite
 CMD yarn serve

PowerDNS pins its base to ubuntu:focal and fixes its EXPOSE (53/udp, where v2 exposed 53/tcp twice); the API key leaves the image build:

-FROM ubuntu:latest
-ARG API_KEY
+FROM ubuntu:focal
 # Setup powerdns repositories
-RUN apt-get update && apt-get install curl gnupg dnsutils rsyslog -y
+RUN apt-get update && apt-get upgrade -y && apt-get install -y curl gnupg dnsutils rsyslog
@@
-RUN apt-get install pdns-server pdns-backend-mysql mariadb-client  -y
+RUN apt-get install -y pdns-server pdns-backend-mysql mariadb-client
@@
-RUN echo "\napi-key=$API_KEY" >> /etc/powerdns/pdns.conf
+#RUN echo "\napi-key=$API_KEY" >> /etc/powerdns/pdns.conf
 COPY start.sh ./
-EXPOSE 53/tcp 53/tcp 8081/tcp
+EXPOSE 53/udp 53/tcp 8081/tcp

The dedicated Traefik image disappears: dockers/traefik/Dockerfile and traefik.toml (62 lines) are removed. TLS and the certificates leave the image and become mounted files (see the compose file below).

The pipeline

The GitLab CI builds each dockers/<tool> and pushes it to the private registry, tagged on the short SHA; two manual jobs promote latest and the version tags:

image: docker:latest

services:
    - docker:dind

stages:
    - build
    - release

before_script:
    - echo "$REGISTRY_PASS" | docker login $REGISTRY_URL -u $REGISTRY_USER --password-stdin 

workflow:
    rules:
        - changes:
            - dockers/**/*

build:
    stage: build
    when: always
    tags:
        - docker20
    script:
        - >
            for i in $(ls dockers);do 
                docker build -t $REGISTRY_URL/$i:$CI_COMMIT_SHORT_SHA -f dockers/$i/Dockerfile dockers/$i
                docker push $REGISTRY_URL/$i:$CI_COMMIT_SHORT_SHA
            done
    only:
        - H2H
    except:
        - tags

release-tags:
    stage: release 
    when: manual       
    tags:
        - docker20
    script:
        - >
            for i in $(ls dockers);do	
                docker pull $REGISTRY_URL/$i:latest
                docker tag $REGISTRY_URL/$i:latest $REGISTRY_URL/$i:CI_COMMIT_REF_NAME
                docker push $REGISTRY_URL/$i:$CI_COMMIT_REF_NAME
            done
    only:
        - tags
    except:
        - branches

release-latest:
    stage: release 
    when: manual       
    tags:
        - docker20
    script:
        - >
            for i in $(ls dockers);do	
                docker pull $REGISTRY_URL/$i:$CI_COMMIT_SHORT_SHA
                docker tag $REGISTRY_URL/$i:$CI_COMMIT_SHORT_SHA $REGISTRY_URL/$i:latest
                docker push $REGISTRY_URL/$i:latest
            done
    only: 
        - H2H

The Salt

Two Salt states. The first one, pwn-machine, deploys the application: it installs the dependencies, clones the repository on the H2H branch, writes the environment and the log rotation, then starts the stack:

{% set docker_user = salt["pillar.get"]("docker_rootless:user","docker") %}

git:
  pkg.installed

sqlite3:
  pkg.installed

pwn-machine:
  git.latest:
    - name:   https://git.example.internal/pwn-machine.git
    - target: /home/{{ docker_user }}/pwn-machine
    - branch: H2H
    - user:   {{ docker_user }}
    - require:
        - pkg: git

#TODO: env1 = salt["pillar.get"]("pwn-machine:env1")
/home/{{ docker_user }}/pwn-machine/.env
  file.managed:
    - user:     {{ docker_user }}
    - group:    {{ docker_user }}
    - mode:     '0400'
    - template: jinja2
    - source:   salt://pwn-machine/files/env.j2
    - require:
      - git: pwn-machine

/etc/logrotate.d/pwn-machine:
  file.managed:
    - user:     root
    - group:    root
    - mode:     '0640'
    - template: jinja2
    - source:   salt://pwn-machine/files/logrotate.j2
    - default:
      docker_user: {{ docker_user }}
    - require:
      - git: pwn-machine

#TODO: file.managed template slat://pwn-machine/file/cron.j2
/etc/cron.daily/pwn-machine:
  file.copy:
    - source: /home/{{ docker_user }}/pwn-machine/rotatedb
    - user:   root
    - group:  root
    - mode:   '0750'
    - require:
      - git:  pwn-machine

docker-compose_up:
  cmd.run:
    - name: cd pwn-machine; docker-compose -f docker-compose-h2h.yml up -d
    - runas: {{ docker_user }}
    - require:
      - file: /home/{{ docker_user }}/pwn-machine/.env

The .env (mode 0400) and the logrotate configuration come from Salt fileserver templates (env.j2, logrotate.j2), not shown here.

The second one, pwnmachine, describes the VM firewall (registry allowed out, VPN access in, proxy) and the manager’s TLS certificates:

firewall_output_registry:
   iptables.append:
     - table: filter
     - chain: OUTPUT
     - jump: ACCEPT
     - match: state
     - connstate: NEW
     - dport: 443
     - proto: tcp
     - sport: 10225:65535
     - destination: registry.example.internal
     - save: True
 
firewall_allow_vpn_adm_tcp:
  iptables.append:
    - chain: INPUT
    - jump: ACCEPT
    - connstate: NEW
    - table: filter
    - match: multiport
    - dport: 53,80,443
    - in-interface: {{ salt["pillar.get"]("vminfo:primary_interface") }}
    - proto: tcp
    - save: True
    - source: {{ salt["pillar.get"]("infra:vpnfront_network") }}
 
firewall_allow_vpn_adm_udp:
  iptables.append:
    - chain: INPUT
    - jump: ACCEPT
    - connstate: NEW
    - table: filter
    - match: state
    - dport: 53
    - sport: 1025:65535
    - in-interface: {{ salt["pillar.get"]("vminfo:primary_interface") }}
    - proto: udp
    - save: True
    - source: {{ salt["pillar.get"]("infra:vpnfront_network") }}
 
firewall_allow_vpn_dev_http_s:
  iptables.append:
    - chain: INPUT
    - jump: ACCEPT
    - connstate: NEW
    - table: filter
    - match: multiport
    - dport: 80,443
    - in-interface: {{ salt["pillar.get"]("vminfo:primary_interface") }}
    - proto: tcp
    - save: True
    - source: 172.16.0.0/12 
 
iptables_output_proxy:
  iptables.append:
    - table: filter
    - chain: OUTPUT
    - jump: ACCEPT
    - match: state
    - connstate: NEW
    - dport: 3128                                                                                                                                                                                              
    - proto: tcp
    - sport: 1025:65535
    - save: True
    - destination: {{ salt["pillar.get"]("infra:proxy")[0] }}
 
/home/docker/.local/share/ssl:
  file.directory:
    - user: docker
    - group: docker
    - makedirs: True
    - recurse:
      - user
      - group
 
pwnmachine.example.internal.crt:
  file.managed:
    - name: /home/docker/.local/share/ssl/pwnmachine.example.internal.crt
    - user: docker
    - group: docker
    - mode: 0640
    - contents_pillar: ssl:pwnmachine.example.internal:cert:contents
    - require:
      - file: /home/docker/.local/share/ssl
 
 
pwnmachine.example.internal.key:
  file.managed:
    - name: /home/docker/.local/share/ssl/pwnmachine.example.internal.key
    - user: docker
    - group: docker
    - mode: 0640
    - contents_pillar: ssl:pwnmachine.example.internal:key:contents
    - require:
      - file: /home/docker/.local/share/ssl
 
#unattend upgrades
unattended-upgrades:
  pkg.installed
 
/etc/apt/apt.conf.d/50unattended-upgrades:
  file.managed:
    - user:     root
    - group:    root
    - mode:     '0644'
    - replace:  true
    - source:   salt://docker/files/50unattended-upgrades
    - require:
      - pkg: unattended-upgrades
 
unattended-upgrades-debconf:
  debconf.set:
    - name: unattended-upgrades
    - data:
        'unattended-upgrades/enable_auto_updates': {'type': 'boolean', 'value': 'true'}
    - require:
      - pkg: unattended-upgrades
 
unattended-upgrades-reconfigure:
    cmd.run:
    - name: dpkg-reconfigure -f noninteractive unattended-upgrades
    - onchanges:
      - debconf: unattended-upgrades-debconf

For the rootless daemon itself, see Docker rootless .

The compose file

The internal deployment uses docker-compose-h2h.yml: the same services as v2, but the images come from the private registry, the manager router is restricted by an IP whitelist on a dedicated domain. Traefik loads an external certificates file:

version: "3.8"

x-pm-settings:
  - &DOCKER_USER_UID "${DOCKER_USER_UID?Please provide docker user UID}"
  - &DOCKER_USER_HOME "${DOCKER_USER_HOME?Please provide docker user home dir}"
  - &PM_DOMAIN "${PM_DOMAIN?Please provide domain name}"
  - &PM_WHITELIST "${PM_WHITELIST?Please provide ip whitelist}"
  - &LETS_ENCRYPT_EMAIL "${LETS_ENCRYPT_EMAIL?Please provide an email for lets encrypt}"
  - &PDNS_MYSQL_ROOT_PASSWORD "${PDNS_MYSQL_ROOT_PASSWORD?Please provide a root passws for mysql database}"
  - &PDNS_MYSQL_DATABASE "pdns"
  - &PDNS_API_KEY "${PDNS_API_KEY?Please provide the API key}"

services:
  powerdns-db:
    container_name: pm_powerdns-db
    image: mariadb:10.7.1
    environment:
      MYSQL_ROOT_PASSWORD: *PDNS_MYSQL_ROOT_PASSWORD
      MYSQL_DATABASE: *PDNS_MYSQL_DATABASE
    networks:
      - dns-net
    volumes:
      - powerdns-data:/var/lib/mysql
    restart: unless-stopped
 
  powerdns:
    container_name: pm_powerdns
    image: registry.example.internal/pwn-machine/powerdns:latest
    environment:
      PDNS_API_KEY: *PDNS_API_KEY
      MYSQL_HOST: powerdns-db
      MYSQL_USER: "root"
      MYSQL_PASSWORD: *PDNS_MYSQL_ROOT_PASSWORD
      MYSQL_DATABASE: *PDNS_MYSQL_DATABASE
    depends_on:
      - powerdns-db
      - traefik
    networks:
      - dns-net
    volumes:
      - powerdns-logs:/logs/pdns
    restart: unless-stopped
    labels:
      - "traefik.enable=true"
      - "traefik.tcp.routers.dns.entrypoints=dns"
      - "traefik.tcp.routers.dns.rule=HostSNI(`*`)"
      - "traefik.tcp.routers.dns.service=powerdns-pwn-machine@docker"
      - "traefik.udp.routers.dns.entrypoints=dns-udp"
      - "traefik.udp.routers.dns.service=powerdns-pwn-machine@docker"
  
  redis:
    container_name: pm_redis
    image: redis:6.2.6
    networks:
      - redis-net
    command:
      - /bin/sh
      - -c
      - |
          redis-server --appendonly yes &
          sleep .5
          redis-cli SET traefik 1 >/dev/null
          wait
    volumes:
      - redis-data:/data
    restart: unless-stopped

  traefik:
    container_name: pm_traefik
    image: traefik:v2.6.1
    ports:
      - "53:53"
      - "53:53/udp"
      - "80:80"
      - "443:443"
    command:
      - "--log.level=WARN"
      - "--accesslog=true"
      - "--accesslog.filepath=/logs/traefik/traefik.json"
      - "--accesslog.format=json"
      - "--accesslog.fields.headers.defaultmode=keep"
      - "--accesslog.fields.headers.names.X-Forwaded-For=keep"
      
      - "--api=true"
      - "--api.insecure=true"
      - "--api.dashboard=false"

      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"

      - "--providers.redis=true"
      - "--providers.redis.endpoints=redis:6379"
      - "--providers.redis.rootkey=traefik" 

      - "--providers.file.directory=/etc/traefik/"
      - "--providers.file.watch=true"

      - "--entrypoints.http.address=:80"
      - "--entrypoints.https.address=:443"
      - "--entrypoints.dns.address=:53"
      - "--entrypoints.dns-udp.address=:53/udp"

      - "--certificatesResolvers.letsencrypt-staging-http=true"
      - "--certificatesResolvers.letsencrypt-staging-http.acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory"
      - "--certificatesResolvers.letsencrypt-staging-http.acme.storage=/tmp/acme-staging-http.json"
      - "--certificatesResolvers.letsencrypt-staging-http.acme.email=${LETS_ENCRYPT_EMAIL}"
      - "--certificatesResolvers.letsencrypt-staging-http.acme.httpchallenge=true"
      - "--certificatesResolvers.letsencrypt-staging-http.acme.httpchallenge.entrypoint=http"

      - "--certificatesResolvers.letsencrypt-staging-dns=true"
      - "--certificatesResolvers.letsencrypt-staging-dns.acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory"
      - "--certificatesResolvers.letsencrypt-staging-dns.acme.storage=/tmp/acme-staging-dns.json"
      - "--certificatesResolvers.letsencrypt-staging-dns.acme.email=${LETS_ENCRYPT_EMAIL}"
      - "--certificatesResolvers.letsencrypt-staging-dns.acme.dnschallenge=true"
      - "--certificatesResolvers.letsencrypt-staging-dns.acme.dnschallenge.provider=pdns"
      - "--certificatesResolvers.letsencrypt-staging-dns.acme.dnschallenge.delayBeforeCheck=5"

      - "--certificatesResolvers.letsencrypt-http=true"
      - "--certificatesResolvers.letsencrypt-http.acme.caServer=https://acme-v02.api.letsencrypt.org/directory"
      - "--certificatesResolvers.letsencrypt-http.acme.storage=/tmp/acme-http.json"
      - "--certificatesResolvers.letsencrypt-http.acme.email=${LETS_ENCRYPT_EMAIL}"
      - "--certificatesResolvers.letsencrypt-http.acme.httpchallenge=true"
      - "--certificatesResolvers.letsencrypt-http.acme.httpchallenge.entrypoint=http"

      - "--certificatesResolvers.letsencrypt-dns=true"
      - "--certificatesResolvers.letsencrypt-dns.acme.caServer=https://acme-v02.api.letsencrypt.org/directory"
      - "--certificatesResolvers.letsencrypt-dns.acme.storage=/tmp/acme-dns.json"
      - "--certificatesResolvers.letsencrypt-dns.acme.email=${LETS_ENCRYPT_EMAIL}"
      - "--certificatesResolvers.letsencrypt-dns.acme.dnschallenge=true"
      - "--certificatesResolvers.letsencrypt-dns.acme.dnschallenge.provider=pdns"
      - "--certificatesResolvers.letsencrypt-dns.acme.dnschallenge.delayBeforeCheck=5"
    volumes:
      - "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
      - "~/.config/traefik/certs.yml:/etc/traefik/certs.yml:ro"
      - "~/.local/share/ssl:/etc/traefik/ssl:ro"
      - traefik-data:/data:rw
      - traefik-logs:/logs/traefik
    environment:
      TRAEFIK_PROVIDERS_REDIS_ENDPOINTS: "redis:6379"
      TRAEFIK_PROVIDERS_REDIS_ROOTKEY: "traefik"
      PDNS_API_KEY: *PDNS_API_KEY
      PDNS_API_URL: "http://powerdns:8081/"
    depends_on:
      - redis
    networks:
      - traefik-net
      - dns-net
      - redis-net
    restart: unless-stopped

  logbeat:
    container_name: pm_logbeat
    image: registry.example.internal/pwn-machine/logbeat:latest
    user: root
    network_mode: none
    volumes:
        - "~/.local/share/docker:/var/lib/docker:ro"
        - "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
        - powerdns-logs:/logs/pdns
        - traefik-logs:/logs/traefik
        - all-logs:/logs/all
    restart: unless-stopped
  
  manager:
    container_name: pm_manager
    image: registry.example.internal/pwn-machine/manager:latest
    depends_on:
      - powerdns
      - redis
      - traefik
    volumes:
      - "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
      - "~/.local/share/docker/volumes:${DOCKER_USER_HOME}/.local/share/docker/volumes"
      - all-logs:/logs/all
    environment:
      PM_REDIS_HOST: "redis://redis"
      PM_TRAEFIK_HTTP_API: "http://traefik:8080/api"
      PM_TRAEFIK_REDIS_ROOT: "traefik"
      PM_POWERDNS_HTTP_API: "http://powerdns:8081"
      PM_POWERDNS_HTTP_API_KEY: *PDNS_API_KEY    
    networks:
      - redis-net
      - dns-net
      - traefik-net
    restart: unless-stopped
    labels:
      - "traefik.enable=true"
      - "traefik.http.middlewares.wl.ipwhitelist.sourcerange=${PM_WHITELIST}"
      - "traefik.http.middlewares.redir.redirectscheme.scheme=https"
      - "traefik.http.middlewares.redir.redirectscheme.permanent=true"
      - "traefik.http.routers.redir-https.entrypoints=http"
      - "traefik.http.routers.redir-https.rule=Host(`pwnmachine.${PM_DOMAIN}`) && PathPrefix(`/`)"
      - "traefik.http.routers.redir-https.middlewares=redir"
      - "traefik.http.routers.redir-https.service=noop@internal"
      - "traefik.http.routers.redir-https.priority=2147483646"
      - "traefik.http.routers.pm-manager.entrypoints=https"
      - "traefik.http.routers.pm-manager.rule=Host(`pwnmachine.${PM_DOMAIN}`) && PathPrefix(`/`)"
      - "traefik.http.routers.pm-manager.tls=true"
      - "traefik.http.routers.pm-manager.middlewares=wl"
      - "traefik.http.routers.pm-manager.service=manager-pwn-machine@docker"
      - "traefik.http.routers.pm-manager.priority=2147483646"

volumes:
  powerdns-logs:
    name: powerdns-logs
  powerdns-data:
    name: powerdns-data
  redis-data:
    name: redis-data
  traefik-data:
    name: traefik-data
  traefik-logs:
    name: traefik-logs
  all-logs:
    name: all-logs

networks:
  redis-net:
    name: pm_redis-net
  dns-net:
    name: pm_dns-net
  traefik-net:
    name: pm_traefik-net

The rootless variant of the same file, without a registry (each image is built locally) and without a whitelist, differs only by this:

@@ -3,11 +3,9 @@ version: "3.8"
 x-pm-settings:
   - &DOCKER_USER_UID "${DOCKER_USER_UID?Please provide docker user UID}"
-  - &DOCKER_USER_HOME "${DOCKER_USER_HOME?Please provide docker user home dir}"
-  - &PM_DOMAIN "${PM_DOMAIN?Please provide domain name}"
-  - &PM_WHITELIST "${PM_WHITELIST?Please provide ip whitelist}"
-  - &LETS_ENCRYPT_EMAIL "${LETS_ENCRYPT_EMAIL?Please provide an email for lets encrypt}"
-  - &PDNS_MYSQL_ROOT_PASSWORD "${PDNS_MYSQL_ROOT_PASSWORD?Please provide a root passws for mysql database}"
+  - &DOCKER_USER_HOME "${DOCKER_USER_HOME?Please provide docker user UID}"
+  - &LETS_ENCRYPT_EMAIL "${LETS_ENCRYPT_EMAIL?Please provide an email for let's encrypt}"
+  - &PDNS_MYSQL_ROOT_PASSWORD "pdns"
   - &PDNS_MYSQL_DATABASE "pdns"
-  - &PDNS_API_KEY "${PDNS_API_KEY?Please provide the API key}"
+  - &PDNS_API_KEY "pdns-secret"
 
 services:
@@ -26,5 +24,6 @@ services:
   powerdns:
     container_name: pm_powerdns
-    image: registry.example.internal/pwn-machine/powerdns:latest
+    build:
+      context: ./dockers/powerdns
     environment:
       PDNS_API_KEY: *PDNS_API_KEY      
@@ -93,7 +92,4 @@ services:
       - "--providers.redis.rootkey=traefik" 
 
-      - "--providers.file.directory=/etc/traefik/"
-      - "--providers.file.watch=true"
-
       - "--entrypoints.http.address=:80"
       - "--entrypoints.https.address=:443"
@@ -132,6 +128,4 @@ services:
     volumes:
       - "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
-      - "~/.config/traefik/certs.yml:/etc/traefik/certs.yml:ro"
-      - "~/.local/share/ssl:/etc/traefik/ssl:ro"
       - traefik-data:/data:rw
       - traefik-logs:/logs/traefik
@@ -143,4 +137,5 @@ services:
     depends_on:
       - redis
+      - powerdns
     networks:
       - traefik-net
@@ -151,10 +146,11 @@ services:
   logbeat:
     container_name: pm_logbeat
-    image: registry.example.internal/pwn-machine/logbeat:latest
+    build:
+      context: ./dockers/logbeat
     user: root
     network_mode: none
     volumes:
         - "~/.local/share/docker:/var/lib/docker:ro"
-        - "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
+        - "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock"
         - powerdns-logs:/logs/pdns
         - traefik-logs:/logs/traefik
@@ -164,5 +160,7 @@ services:
   manager:
     container_name: pm_manager
-    image: registry.example.internal/pwn-machine/manager:latest
+    image: "pm-manager"
+    build:
+      context: ./dockers/manager
     depends_on:
       - powerdns
@@ -186,17 +184,16 @@ services:
     labels:
       - "traefik.enable=true"
-      - "traefik.http.middlewares.wl.ipwhitelist.sourcerange=${PM_WHITELIST}"
       - "traefik.http.middlewares.redir.redirectscheme.scheme=https"
       - "traefik.http.middlewares.redir.redirectscheme.permanent=true"
       - "traefik.http.routers.redir-https.entrypoints=http"
-      - "traefik.http.routers.redir-https.rule=Host(`pwnmachine.${PM_DOMAIN}`) && PathPrefix(`/`)"
+      - "traefik.http.routers.redir-https.rule=PathPrefix(`/`)"
       - "traefik.http.routers.redir-https.middlewares=redir"
       - "traefik.http.routers.redir-https.service=noop@internal"
       - "traefik.http.routers.redir-https.priority=2147483646"
-      - "traefik.http.routers.pm-manager.entrypoints=https"
-      - "traefik.http.routers.pm-manager.rule=Host(`pwnmachine.${PM_DOMAIN}`) && PathPrefix(`/`)"
-      - "traefik.http.routers.pm-manager.tls=true"
-      - "traefik.http.routers.pm-manager.middlewares=wl"
-      - "traefik.http.routers.pm-manager.service=manager-pwn-machine@docker"
+      - "traefik.http.routers.pm-manager-https.entrypoints=https"
+      - "traefik.http.routers.pm-manager-https.rule=PathPrefix(`/`)"
+      - "traefik.http.routers.pm-manager-https.tls=true"
+      - "traefik.http.routers.pm-manager-https.tls.certresolver=letsencrypt-staging-http"
+      - "traefik.http.routers.pm-manager-https.service=manager-pwn-machine@docker"
       - "traefik.http.routers.pm-manager.priority=2147483646"
 

What it is worth

The self-hosted pentest workstation is the bug bounty equivalent of what the CI status light is for development: a setup that runs permanently and tells you what is happening. PwnMachine provides the infrastructure (DNS, reverse proxy, interface) and each pentest tool plugs into it.

PwnMachine is still open source (github.com/yeswehack/pwn-machine) and still active. The concept has not moved: a pentest workstation that deploys in one command, with DNS and TLS built in. A web interface adds tools: a Docker Hub image to pick, a container to create.