Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions .github/workflows/goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,49 @@ on:
- "*"

permissions:
contents: write
contents: read

jobs:
test:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: go.mod

- name: Verify and test the tagged source
run: |
go mod download
go mod verify
go mod tidy -diff
go test -race ./...

goreleaser:
runs-on: ubuntu-latest
needs: [test]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
-
name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: '>=1.22.2'
go-version-file: go.mod

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
distribution: goreleaser
version: latest
version: v2.17.0
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31 changes: 26 additions & 5 deletions .github/workflows/lint-test-build.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
name: build-push
on:
pull_request:
push:
branches:
- main
tags:
- "*"
workflow_dispatch:

permissions:
contents: read

jobs:
lint-test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
Expand All @@ -17,10 +25,12 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
with:
version: latest
version: v2.12.2

- name: Install dependencies
run: go get .
run: |
go mod download
go mod verify

- run: go build

Expand All @@ -38,10 +48,21 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

run:
publish-ghcr:
if: github.event_name != 'pull_request'
needs: [lint-test]
uses: libops/.github/.github/workflows/build-push.yaml@3ef8469c16c40842cc1b0b9195df2850ad8e616f # main
uses: libops/.github/.github/workflows/build-push.yaml@3bdb895c3fd05490b0c6a1341e630ac554842468 # main
permissions:
contents: read
packages: write

publish-gar:
if: github.event_name != 'pull_request'
needs: [lint-test]
uses: libops/.github/.github/workflows/build-push.yaml@3bdb895c3fd05490b0c6a1341e630ac554842468 # main
with:
docker-registry: us-docker.pkg.dev/libops-images/public
permissions:
contents: read
id-token: write
secrets: inherit
2 changes: 1 addition & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
before:
hooks:
- go mod tidy
- go mod tidy -diff
builds:
- binary: ppb
env:
Expand Down
83 changes: 65 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ PPB runs as a reverse proxy with intelligent rate limiting:
1. **Request handling**: Each authorized request attempts to power on the target machine if needed
2. **Rate limiting**: Power-on attempts are rate limited with a configurable cooldown period (default 30s)
3. **API protection**: Prevents hammering Google Cloud APIs during high traffic periods
4. **IP Security**: Only requests from allowed IP ranges can trigger machine power-on attempts
4. **Client filter**: Only explicitly allowed original-client CIDRs can trigger power-on after a configured, trusted proxy suffix is removed
5. **Bounded readiness retry**: Retries connection establishment while a VM or Direct VPC path becomes reachable, without adding application-level request retries

This approach balances responsiveness with API efficiency - machines get powered on when traffic arrives, but multiple requests don't spam the Google Cloud APIs.

Expand All @@ -53,13 +54,11 @@ type: google_compute_engine
port: 80
scheme: http
allowedIps:
- 127.0.0.1/32
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ipForwardedHeader: X-Forwarded-For # header to check for real client IP
ipDepth: 0 # depth in X-Forwarded-For chain
- 127.0.0.1/32 # replace with an original-client CIDR for deployment
ipForwardedHeader: "" # direct peer address; configure only for a proven proxy chain
ipDepth: 0 # trusted proxy hops after the client in X-Forwarded-For
powerOnCooldown: 30 # seconds between power-on attempts (default: 30)
powerOnTimeout: 360 # total queued/API/instance-ready budget (default: 360)
# metadata about the machine that will be turned on
machineMetadata:
project_id: foo
Expand All @@ -68,7 +67,9 @@ machineMetadata:
usePrivateIp: false # true for VPC-native setups
# Optional proxy timeout configuration (defaults shown)
proxyTimeouts:
dialTimeout: 120 # Connection dial timeout in seconds
dialTimeout: 120 # Total connection retry window in seconds
dialAttemptTimeout: 5 # Timeout for one connection attempt in seconds
dialRetryInterval: 1 # Delay between connection attempts in seconds
keepAlive: 120 # TCP keep-alive timeout in seconds
idleConnTimeout: 90 # Idle connection timeout in seconds
tlsHandshakeTimeout: 10 # TLS handshake timeout in seconds
Expand All @@ -86,9 +87,12 @@ proxyTimeouts:
| `scheme` | string | ✅ | - | Protocol scheme (`http` or `https`) |
| `allowedIps` | []string | ✅ | - | CIDR ranges of IPs allowed to access the proxy |
| `ipForwardedHeader` | string | ❌ | `""` | Header to check for real client IP (e.g., `X-Forwarded-For`) |
| `ipDepth` | int | ❌ | `0` | Depth in forwarded header chain (0 = rightmost IP) |
| `ipDepth` | int | ❌ | `0` | Trusted proxy hops after the client (0 selects rightmost IP) |
| `powerOnCooldown` | int | ❌ | `30` | Seconds between power-on attempts (rate limiting) |
| `proxyTimeouts.dialTimeout` | int | ❌ | `120` | Connection dial timeout in seconds |
| `powerOnTimeout` | int | ❌ | `360` | Total queued, API, and instance-ready budget in seconds |
| `proxyTimeouts.dialTimeout` | int | ❌ | `120` | Total connection retry window in seconds |
| `proxyTimeouts.dialAttemptTimeout` | int | ❌ | `5` | Timeout for one connection attempt in seconds |
| `proxyTimeouts.dialRetryInterval` | int | ❌ | `1` | Delay between connection attempts in seconds |
| `proxyTimeouts.keepAlive` | int | ❌ | `120` | TCP keep-alive timeout in seconds |
| `proxyTimeouts.idleConnTimeout` | int | ❌ | `90` | Idle connection timeout in seconds |
| `proxyTimeouts.tlsHandshakeTimeout` | int | ❌ | `10` | TLS handshake timeout in seconds |
Expand All @@ -101,6 +105,24 @@ proxyTimeouts:

Deploy this service on **Google Cloud Run** as the public endpoint for your application. Configure the `machineMetadata` to point to your GCE VM running the actual application stack. Only requests from allowed IPs will power on the VM and be proxied through. Set to `0.0.0.0/0` to allow any request to power on the machine.

Treat `ipForwardedHeader` and `ipDepth` as a proxy-chain contract, not an IP
parser convenience. PPB selects from the right edge so an attacker-supplied
prefix cannot replace the client hop. A missing header, malformed address, or
chain shorter than `ipDepth + 1` is denied; PPB never falls back to the proxy's
`RemoteAddr` when a trusted header is configured. Use depth `0` only for a
direct Cloud Run path whose hosted test proves Google appends the original
client last. Duplicate field lines are folded before suffix selection. An added
external Application Load Balancer normally adds a hop and requires a different
depth. Do not enable a forwarded header for an ingress path that has not proven
its appended suffix. Prefer Cloud Run IAM or a controlled load balancer/Cloud
Armor policy when caller authentication, rather than a wake-up filter, is
required. Before proxying, PPB replaces forwarding identity headers with the
single validated client address.

`proxyTimeouts.dialTimeout` bounds the complete TCP connection-establishment retry window. Each attempt is bounded by `dialAttemptTimeout`, with `dialRetryInterval` between failures. The readiness loop runs in the transport dialer before an HTTP connection exists; PPB does not add application-level request or status retries. Go's standard transport can retry requests it defines as replayable when a pooled connection is found stale. If the connection window expires, PPB returns `503 Service Unavailable` with `Retry-After: 5` so the client can make a deliberate retry. Request cancellation stops GCE polling, queued power-on work, and connection retry. HTTPS handshake readiness is outside the TCP retry loop and fails with the same retryable 503 response.

For Direct VPC egress, use a supported `/26` or larger subnet with sufficient free addresses, grant the Cloud Run service agent subnet use, and authorize the whole Cloud Run subnet CIDR at the VM firewall. Cloud Run addresses are ephemeral; never build the firewall around one revision address. PPB tolerates initial connection refusal and timeout within the configured retry window, but clients must still tolerate occasional connection resets after a connection has been established.

### Environment Variables

PPB also supports these environment variables for runtime configuration:
Expand Down Expand Up @@ -138,10 +160,12 @@ resource "google_project_iam_custom_role" "compute-start" {
### Assign Role to Service Account

```hcl
resource "google_project_iam_member" "ppb_service_account" {
project = var.project
role = google_project_iam_custom_role.compute-start.name
member = "serviceAccount:${var.service_account_email}"
resource "google_compute_instance_iam_member" "ppb_service_account" {
project = var.project
zone = var.zone
instance_name = var.instance_name
role = google_project_iam_custom_role.compute-start.name
member = "serviceAccount:${var.service_account_email}"
}
```

Expand All @@ -154,7 +178,9 @@ gcloud iam roles create startVM \
--description="Minimal permissions for PPB to control compute instances" \
--permissions="compute.instances.start,compute.instances.resume,compute.instances.get"

gcloud projects add-iam-policy-binding PROJECT_ID \
gcloud compute instances add-iam-policy-binding INSTANCE_NAME \
--zone=ZONE \
--project=PROJECT_ID \
--member="serviceAccount:SERVICE_ACCOUNT_EMAIL" \
--role="projects/PROJECT_ID/roles/startVM"
```
Expand All @@ -166,30 +192,51 @@ gcloud projects add-iam-policy-binding PROJECT_ID \
```bash
docker build -t us-docker.pkg.dev/your/gar/ppb:development .
docker run \
-p 8080:8080 \
--network=host \
-v $GOOGLE_APPLICATION_CREDENTIALS:/app/svc.json:ro \
-v ./ppb.yaml:/app/ppb.yaml:ro \
--env PPB_CONFIG_PATH=/app/ppb.yaml \
--env GOOGLE_APPLICATION_CREDENTIALS=/app/svc.json \
--env PRIVATE_IP=false \
--env LOG_LEVEL=DEBUG \
us-docker.pkg.dev/your/gar/ppb:development
```

PPB will act as a local ingress proxy to your GCE VM. Visit http://localhost:8080 and PPB will power on your target VM (if needed) then proxy your requests through to the application running on the VM.

The host-network example is for Linux and makes the checked-in loopback
allowlist match the direct browser peer. On Docker Desktop, set `allowedIps` to
the exact host-gateway address reported inside the container instead of adding
a broad private range.

### Google Cloud Run Deployment

Deploy PPB to Cloud Run and configure it as your application's public endpoint:

```bash
gcloud run deploy ppb \
--image=us-docker.pkg.dev/your/gar/ppb:latest \
--image=us-docker.pkg.dev/your/gar/ppb:VERSION@sha256:DIGEST \
--set-secrets="/app/ppb.yaml=ppb-config:latest" \
--set-env-vars="PPB_CONFIG_PATH=/app/ppb.yaml" \
--service-account=your-ppb-service-account@project.iam.gserviceaccount.com \
--allow-unauthenticated \
--port=8080 \
--timeout=600 \
--max-instances=1 \
--network=NETWORK \
--subnet=SUBNET \
--vpc-egress=private-ranges-only \
--region=us-central1
```

Keep the Cloud Run request timeout greater than `powerOnTimeout +
dialTimeout`, with enough margin for the proxied application response. The
defaults use a 360-second power budget and 120-second connection window, so the
example configures 600 seconds.

Keep one active PPB instance so concurrent requests share its in-process power
coordinator. Revision overlap can briefly run an old and new instance at once;
PPB reconciles a conflicting start or resume by joining the observed VM
transition. Grant the service account the custom start role on the target
instance, not across the project.

Your users will access `https://ppb-xxx-uc.a.run.app` which automatically powers on your GCE VM and proxies traffic through.
81 changes: 49 additions & 32 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ func startPingRoutine(ctx context.Context, wg *sync.WaitGroup, c *config.Config,
slog.Debug("Ping failed", "url", pingURL, "error", err)
continue
}
resp.Body.Close()
if err := resp.Body.Close(); err != nil {
slog.Debug("Unable to close ping response body", "error", err)
}

slog.Debug("Ping successful", "url", pingURL, "status", resp.StatusCode)
}
Expand All @@ -83,11 +85,6 @@ func main() {

slog.Debug("Loaded config", "config", c)

// Set default cooldown interval if not specified
if c.PowerOnCooldown <= 0 {
c.PowerOnCooldown = 30
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
Expand All @@ -96,33 +93,12 @@ func main() {
wg.Add(1)
go startPingRoutine(ctx, &wg, c, 30*time.Second)

http.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "OK")
})

p := proxy.New(c)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if !c.IpIsAllowed(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

// Attempt to power on machine with cooldown protection
reqCtx := context.Background()
err := c.Machine.PowerOnWithCooldown(reqCtx, c.PowerOnCooldown)
if err != nil {
slog.Error("Power-on attempt failed", "err", err)
http.Error(w, "Backend not available", http.StatusServiceUnavailable)
return
}

p.SetRequestHeaders(r)
p.SetHost()
p.ServeHTTP(w, r)
})

server := &http.Server{Addr: ":8080"}
server := &http.Server{
Addr: ":8080",
Handler: newHandler(c, p),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
slog.Info("Server listening on :8080")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
Expand All @@ -143,3 +119,44 @@ func main() {
wg.Wait()
slog.Info("Shutdown complete")
}

func newHandler(c *config.Config, backend http.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthcheck", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "OK")
})

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
clientIP, err := c.AllowedClientIP(r)
if err != nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

// Do not pass attacker-controlled forwarding identities to the backend.
// Preserve one canonical, already validated original-client address.
r.Header.Del("Forwarded")
r.Header.Del("X-Forwarded-For")
r.Header.Del("X-Real-IP")
if c.IpForwardedHeader != "" {
r.Header.Del(c.IpForwardedHeader)
}
r.Header.Set("X-Forwarded-For", clientIP.String())

// Attempt to power on the machine within the request lifetime. Waiting
// requests can then be cancelled cleanly during disconnect or shutdown.
powerCtx, powerCancel := context.WithTimeout(r.Context(), time.Duration(c.PowerOnTimeout)*time.Second)
err = c.Machine.PowerOnWithCooldown(powerCtx, c.PowerOnCooldown)
powerCancel()
if err != nil {
slog.Error("Power-on attempt failed", "err", err)
w.Header().Set("Retry-After", "5")
http.Error(w, "Backend not available", http.StatusServiceUnavailable)
return
}

backend.ServeHTTP(w, r)
})
return mux
}
Loading