Add Docker image and compose for local client / exit-node runs

One multi-stage image (golang:1.26-alpine -> alpine:3.22, CGO off) serving
both roles via an env-driven entrypoint:

- client: SOCKS5 proxy, no special privileges, published on 127.0.0.1:1080
- exit-node: raw sockets + the kernel-RST drop, confined to the container's
  network namespace (NET_RAW/NET_ADMIN via compose), so the iptables rule
  can never touch the host — the containerized variant of the scoped rule
  the README already recommends

docker-compose.yml runs the two ends behind separate profiles; .env.example
documents TRANSPORT (yandex|vyandex|oneme), DOC_URL, MAX_TOKEN/MAX_UID, etc.
This commit is contained in:
Maks Kukarin
2026-09-12 14:22:08 +05:00
parent c611864161
commit b2da6daf33
5 changed files with 193 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Keep the build context lean: only Go sources + go.mod/go.sum are needed
# (export_ios*.go compile out via //go:build ios; mobile app dirs are inert).
.git
.gitignore
.idea
.claude
# Prebuilt binaries — never belong in the image.
openflux
openflux-*
output/
# Mobile client sources & scripts — not part of the linux binary.
ios-app/
build_android.sh
build_ios.sh
build_ios_app.sh
# Local config & docs
.env
.env.local
README.md
README.ru.md
# Captures / artifacts
*.pcap
*.pcapng
capture.txt
+22
View File
@@ -0,0 +1,22 @@
# Copy to .env (gitignored) or export inline. All optional except the
# credentials of whichever transport you run.
# Transport channel: yandex (legacy docs editor) | vyandex (new volga editor) | oneme (MAX)
TRANSPORT=yandex
# yandex / vyandex: public Yandex document URL shared by client and exit node.
DOC_URL=
# oneme: MAX Web token + the *other* side's user id (client dials the exit node's uid).
MAX_TOKEN=
MAX_UID=
# Client only: SOCKS5 listen address inside the container (published as 127.0.0.1:1080).
SOCKS5_LISTEN=:1080
# Exit node only: pin a dedicated egress/alias IP. Usually unnecessary inside
# the container's own netns — leave empty for auto-detection.
EXIT_LOCAL_IP=
# 1 enables verbose packet-level logging (noisy).
DEBUG=0
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
# OpenFlux (openflux) — one image, two roles:
# client — SOCKS5 proxy, no special privileges
# exit-node — raw sockets + RST-drop, needs NET_RAW/NET_ADMIN (see compose)
# Role is selected at runtime by the entrypoint from ROLE=client|exit-node.
FROM golang:1.26-alpine AS build
WORKDIR /src
# Cache module downloads across builds.
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/openflux .
FROM alpine:3.22
# ca-certificates: all transports are TLS (wss/https) to Yandex/MAX endpoints.
# iptables: the exit node must drop kernel RSTs inside its network namespace.
RUN apk add --no-cache ca-certificates iptables
COPY --from=build /out/openflux /usr/local/bin/openflux
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/openflux
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+49
View File
@@ -0,0 +1,49 @@
# OpenFlux local runner. One image, two mutually independent services —
# the two ends never talk to each other directly; they meet on the transport
# channel (the Yandex doc / the MAX call). Start only the end you need:
#
# docker compose --profile client up -d --build # SOCKS5 on 127.0.0.1:1080
# docker compose --profile exit-node up -d --build
#
# Configure via .env (see .env.example) or inline env vars:
# DOC_URL=... MAX_TOKEN=... MAX_UID=... TRANSPORT=vyandex docker compose --profile client up -d
#
# Privileges, deliberately asymmetric:
# client — none. It dials out like a browser and serves SOCKS5 locally.
# exit-node — NET_RAW (raw sockets) + NET_ADMIN (the RST-drop rule), confined
# to this container's netns; the rule can never touch the host.
services:
client:
build: .
image: openflux:local
restart: unless-stopped
profiles: [client]
environment:
ROLE: client
TRANSPORT: ${TRANSPORT:-yandex}
URL: ${DOC_URL:-}
MAX_TOKEN: ${MAX_TOKEN:-}
MAX_UID: ${MAX_UID:-}
SOCKS5_LISTEN: ${SOCKS5_LISTEN:-:1080}
DEBUG: ${DEBUG:-0}
# No auth on the SOCKS5 server — keep it bound to the host loopback only.
ports:
- "127.0.0.1:1080:1080"
exit-node:
build: .
image: openflux:local
restart: unless-stopped
profiles: [exit-node]
environment:
ROLE: exit-node
TRANSPORT: ${TRANSPORT:-yandex}
URL: ${DOC_URL:-}
MAX_TOKEN: ${MAX_TOKEN:-}
MAX_UID: ${MAX_UID:-}
LOCAL_IP: ${EXIT_LOCAL_IP:-}
DEBUG: ${DEBUG:-0}
cap_add:
- NET_RAW
- NET_ADMIN
+65
View File
@@ -0,0 +1,65 @@
#!/bin/sh
# Maps environment variables to openflux flags and, for the exit node,
# installs the kernel-RST-drop rule *inside this container's netns*.
#
# Why the rule: the exit node's TCP connections live in a userspace (gVisor)
# stack, so the kernel has no socket for them and answers every inbound
# SYN-ACK with an RST, tearing the tunnel down. Confining the DROP to the
# container netns is the scoped variant of upstream's host-wide rule — it
# cannot affect the host or other containers.
set -eu
role="${ROLE:-client}"
transport="${TRANSPORT:-yandex}"
listen="${SOCKS5_LISTEN:-:1080}"
case "$role" in
client|exit-node) ;;
*)
echo "ROLE must be 'client' or 'exit-node' (got '$role')" >&2
exit 2
;;
esac
case "$transport" in
yandex|vyandex|oneme) ;;
*)
echo "TRANSPORT must be one of yandex, vyandex, oneme (got '$transport')" >&2
exit 2
;;
esac
set -- "--$role" --transport "$transport"
if [ "$role" = client ]; then
set -- "$@" --socks5 "$listen"
fi
if [ -n "${URL:-}" ]; then
set -- "$@" --url "$URL"
fi
if [ -n "${MAX_TOKEN:-}" ]; then
set -- "$@" --maxToken "$MAX_TOKEN"
fi
if [ -n "${MAX_UID:-}" ]; then
set -- "$@" --maxUid "$MAX_UID"
fi
if [ -n "${LOCAL_IP:-}" ]; then
# Optional: pin the egress IP (alias IP) so the RST drop could be scoped
# with `-s <ip>` too; inside a dedicated container netns it's usually
# unnecessary.
set -- "$@" --local-ip "$LOCAL_IP"
fi
case "${DEBUG:-0}" in
1|true|yes) set -- "$@" --debug ;;
esac
if [ "$role" = exit-node ]; then
echo "[entrypoint] dropping outbound TCP RSTs inside the container netns"
if ! iptables -A OUTPUT -p tcp --tcp-flags RST RST -j DROP; then
echo "[entrypoint] WARNING: iptables failed (missing NET_ADMIN?); kernel RSTs will kill tunnel connections" >&2
fi
fi
echo "[entrypoint] exec: openflux $*"
exec openflux "$@"