Skip to content

peinser/helm-onion

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

helm-onion

A Helm chart (and the container image it runs) for deploying a single Tor v3 onion service in front of an existing Kubernetes Service.

Goals

This project has exactly one job: take an in-cluster backend (e.g. a web app you already deploy) and expose it as a Tor v3 hidden service, with sensible security defaults and as little surface area as possible.

What this is. A Helm chart designed to be consumed as a dependency of an application chart, plus a minimal Debian-based Tor image built from the official Tor Project apt repository. One Deployment, one Secret, one ConfigMap (and an optional hardened nginx in front).

What this is not.

  • A general Tor operator. If you want CRDs, multiple onion services per cluster managed declaratively, automatic key rotation, etc., look at tor-controller. It does far more than this chart does, with corresponding complexity.
  • A Tor relay, bridge, or exit node; different deployment shapes, different security posture, out of scope.
  • A SOCKS proxy. The chart deliberately disables SocksPort in torrc.
  • An identity manager. You generate the onion keys; the chart consumes them. The .onion address is yours and stable across reinstalls.

The whole point is to be boring and small so it can drop into a parent chart's dependencies: block without forcing operational complexity on the consumer and to be as explicit as possible. Very nice for gitops.

What's in the repo

Artifact Path Published as
Container image docker/Dockerfile ${OCI_REGISTRY}/library/tor:<appVersion>
Helm chart helm/ ${OCI_REGISTRY}/library/charts/tor:<chart-version>

Both are pushed by GitHub Actions on push to main / development (.github/workflows/image.yml, .github/workflows/helm.yml).

Architecture

        Tor network
            │
            ▼
   ┌──────────────────┐         ┌─────────────────┐
   │  tor (1 pod)     │         │  nginx (opt.)   │
   │  HiddenService v3│ ──────▶ │  proxy + harden │ ──▶  backend.svc:port
   │  emptyDir /var/  │         │  (rate limit,   │
   │  lib/tor         │         │   no gzip,      │
   │  keys: Secret    │         │   no headers)   │
   └──────────────────┘         └─────────────────┘

When nginx.enabled=false (default), tor connects directly to backend.host:backend.port. When nginx.enabled=true, tor connects to the in-cluster nginx Service, which proxies to the backend with a hardened configuration suited to onion traffic (compression off, identifying request headers stripped, rate-limited).

Quickstart (as a Helm dependency)

In your parent chart's Chart.yaml:

dependencies:
  - name: tor
    version: 0.1.0
    repository: oci://harbor.peinser.com/library/charts
    condition: tor.enabled

In your parent chart's values.yaml:

tor:
  enabled: true

  backend:
    host: my-app.my-namespace.svc.cluster.local
    port: 8080

  hiddenService:
    existingSecret: my-onion-keys

  # Optional reverse proxy; recommended when the backend doesn't sanitize
  # request headers itself.
  nginx:
    enabled: true

Resources are named <release>-tor and <release>-nginx the parent release name is the prefix.

Generating onion-service keys

You need three files: hs_ed25519_secret_key, hs_ed25519_public_key, and hostname. Two common ways to produce them:

Plain tor (random .onion)

mkdir -p ./onion-keys
docker run --rm --user 100:101 \
  --entrypoint sh \
  -v "$PWD/onion-keys:/data" \
  harbor.peinser.com/library/tor:<appVersion> \
  -c '
    install -d -m 0700 /data/hidden_service
    tor --quiet \
        --DataDirectory /data \
        --HiddenServiceDir /data/hidden_service \
        --HiddenServiceVersion 3 \
        --HiddenServicePort "80 127.0.0.1:80" \
        --SocksPort 0 --ORPort 0 --DirPort 0 \
        --DisableNetwork 1 &
    pid=$!
    while [ ! -f /data/hidden_service/hostname ]; do sleep 0.2; done
    kill $pid
    cat /data/hidden_service/hostname
  '

mkp224o (vanity .onion, e.g. starts with test)

mkp224o brute-forces ed25519 keypairs whose derived .onion matches a chosen prefix.

# brew install mkp224o   (or build from source)
mkp224o -d ./onion-keys -n 1 test

# mkp224o writes ./onion-keys/test...xyz.onion/ rename to hidden_service
mv ./onion-keys/test*.onion ./onion-keys/hidden_service

Either path produces:

onion-keys/hidden_service/
├── hs_ed25519_secret_key   # binary, never share
├── hs_ed25519_public_key   # binary
└── hostname                # text, your .onion address

Loading keys into the cluster

Recommended: via an existing Secret

kubectl --namespace my-namespace create secret generic my-onion-keys \
  --from-file=hs_ed25519_secret_key=onion-keys/hidden_service/hs_ed25519_secret_key \
  --from-file=hs_ed25519_public_key=onion-keys/hidden_service/hs_ed25519_public_key \
  --from-file=hostname=onion-keys/hidden_service/hostname
tor:
  hiddenService:
    existingSecret: my-onion-keys

This integrates with Sealed Secrets or External Secrets produce the Secret through whichever flow you already use, then point the chart at its name.

Alternative: inline via values

Only useful when your values file is itself encrypted at rest (sops, etc.).

tor:
  hiddenService:
    keys:
      hsSecretKey: "<base64 of hs_ed25519_secret_key>"
      hsPublicKey: "<base64 of hs_ed25519_public_key>"
      hostname:    "<your-address>.onion"

The chart renders a chart-managed Secret from these values. The keys then live in the helm release storage as well as in the rendered Secret.

Values reference

Common knobs only. See helm/values.yaml for the full set with defaults and inline documentation.

Key Default Notes
backend.host "" Required when nginx.enabled=false. Cluster service the onion routes to.
backend.port 80
onionService.port 80 Public port advertised on the .onion.
hiddenService.existingSecret "" Name of pre-existing Secret holding the three key files.
hiddenService.keys.* "" Inline alternative; see above.
image.repository harbor.peinser.com/library/tor Override for your own registry.
image.tag "" Falls back to Chart.AppVersion.
nginx.enabled false Insert hardened nginx between tor and the backend.
nginx.rateLimit.rate 60r/m Per-source rate limit.
nginx.rateLimit.burst 120
networkPolicy.enabled true Tor: deny ingress. Nginx: ingress only from tor; egress only to backend + DNS.

Security model

  • Pod runs as debian-tor (UID 100 / GID 101), runAsNonRoot, readOnlyRootFilesystem, all capabilities dropped, seccomp RuntimeDefault, no service-account token mounted.
  • torrc disables every listener except the hidden service: SocksPort/ControlPort/ORPort/DirPort/ExitRelay = 0. SafeLogging 1. Intro-point DoS defenses on.
  • Onion keys are mounted from a Secret (read-only, defaultMode 0400), copied by an init container into emptyDir at /var/lib/tor/hidden_service with mode 0700 / 0600 tor refuses the Secret tmpfs mount mode directly.
  • Deployment strategy: Recreate so two tor pods never publish the same HS descriptor concurrently during a rollout.
  • NetworkPolicy on by default: tor accepts no in-cluster ingress (it only initiates outbound). When nginx is enabled, nginx accepts ingress only from the tor pod and may egress only to the backend port + DNS.
  • nginx reverse proxy (when enabled): server_tokens off, gzip off (BREACH-style risk), client-supplied X-Forwarded-* headers stripped, per-source rate limit, all temp paths under /tmp (read-only root FS).

Building the image

The Dockerfile installs tor from the official Tor Project Debian repository (deb.torproject.org), which tracks upstream stable promptly and is GPG-signed. Two stages:

  • validate CI smoke test (tor --version, tor --verify-config against a minimal known-valid config).
  • production slim final image, runs as debian-tor (UID 100).
docker buildx build --target production -f docker/Dockerfile -t tor:dev docker/

The debian-tor user is pre-created with stable UID 100 / GID 101 so the chart's pod security context is portable across image rebuilds.

CI / publishing

Workflow Trigger What it does
image.yml docker/** or helm/Chart.yaml change on main/development Builds & pushes ${OCI_REGISTRY}/library/tor:<appVersion>-<sha>. Image version comes from helm/Chart.yaml's appVersion.
helm.yml helm/** change on main/development helm linthelm packagehelm push to ${OCI_REGISTRY}/library/charts. On main only, also retags as 0.0.0-latest via crane.

Both expect these GitHub Actions repo settings:

  • Variable OCI_REGISTRY registry host (e.g. harbor.peinser.com).
  • Secrets OCI_REGISTRY_HARBOR_LOGIN and OCI_REGISTRY_HARBOR_SECRET.
  • Self-hosted runner group on-prem.

License

BSD-3-Clause (matches upstream Tor). See LICENSE.

About

A Helm dependency and Tor image for deploying applications on the Tor network with Kubernetes.

Topics

Resources

License

Stars

Watchers

Forks

Contributors