From b2da6daf33b12eabaf01cf4bbe900461435c5230 Mon Sep 17 00:00:00 2001 From: Maks Kukarin Date: Sat, 12 Sep 2026 14:21:07 +0500 Subject: [PATCH] Add Docker image and compose for local client / exit-node runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .dockerignore | 28 +++++++++++++++++++ .env.example | 22 +++++++++++++++ Dockerfile | 29 ++++++++++++++++++++ docker-compose.yml | 49 +++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 65 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 docker/entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6190a88 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c27eda --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ac8d54 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2c65ec5 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..674fbe5 --- /dev/null +++ b/docker/entrypoint.sh @@ -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 ` 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 "$@"