Skip to content
Open
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
3 changes: 1 addition & 2 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ name: PR
on:
pull_request:
types: [opened, synchronize, reopened, labeled]

jobs:
test:
name: Test package
Expand All @@ -12,7 +11,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4

- name: Validate hoprd.cfg.yaml
- name: Validate hoprd.cfg.yaml.tpl
run: ./test/validate-hoprd-config.sh

- name: Set up Docker Buildx
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,5 @@ upstream image's own defaults/shape changed.

Update `docker-compose.yml`'s `UPSTREAM_VERSION` build arg, then re-run
`just validate-config` - hoprd 4.0's config schema is strict (unknown top-level keys in
`hoprd.cfg.yaml` are a hard error), so a minor version bump can break it silently
`hoprd.cfg.yaml.tpl` are a hard error), so a minor version bump can break it silently
otherwise.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ FROM europe-west3-docker.pkg.dev/hoprassociation/docker-images/hoprd:${UPSTREAM_
# not used at the moment, but might be useful in the future
ENV DAPPNODE=true

ADD hoprd.cfg.yaml /app/hoprd.cfg.yaml
ADD hoprd.cfg.yaml.tpl /app/hoprd.cfg.yaml.tpl
ADD entrypoint.sh /bin/entrypoint.sh

RUN chmod +rx /bin/entrypoint.sh
Expand Down
50 changes: 33 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,28 @@ Runs a [GnosisVPN](https://vpn.gnosis.eth.limo) exit node on DAppNode: a HOPR no
- **`gnosisvpn-server`** - runs [`gnosis_vpn-server`](https://github.com/gnosis/gnosis_vpn-server), which manages a
real WireGuard interface (`wggvpn`) and terminates/forwards client traffic to the internet. `gnosisvpn-server` runs as an ordinary container with just `cap_add: NET_ADMIN`. That's
still enough to create a real kernel WireGuard interface and manage it via `wg-quick`
(`--sync-wg-interface`) - verified directly. It shares `node`'s network namespace
(`network_mode: "service:node"`) rather than getting its own: `hoprd`'s
`session_ip_forwarding` needs a literal, stable `ip:port` for `gnosisvpn-server`, and a
non-core DAppNode package can't pin it a static IP on a custom docker network (see below),
so instead it's reached over loopback (`127.0.0.1` - see `hoprd.cfg.yaml`), which is stable
by construction.
(`--sync-wg-interface`) - verified directly. It runs as a normal DAppNode service, on the
same `dncore_network` every DAppNode package service gets attached to (a non-core package
can't get `network_mode: "service:node"` here either - DAppNode's `dappmanager` forces that
`networks:` attachment onto every service regardless, and Docker Compose rejects
`network_mode` and `networks` together on the same service). Since `hoprd`'s
`session_ip_forwarding` needs a literal `ip:port` (hostnames are rejected by hoprd's own
config parser) and a non-core package can't pin a static IP on `dncore_network` either (see
below), `node`'s `entrypoint.sh` resolves `gnosisvpn-server`'s DAppNode DNS alias
(`gnosisvpn-server.<dnpName>.dappnode`) to its current IP at boot and renders that into
`hoprd.cfg.yaml.tpl` before `hoprd` starts (see `GNOSISVPN_SERVER_HOST` in
`docker-compose.yml`). Unlike the loopback address this replaced, this IP is **not** fixed
across installs, and isn't guaranteed stable across a `gnosisvpn-server` recreate - see
"Known limitations" below.

## Known limitations vs. production

A regular (non-core) DAppNode package can't get `network_mode: host` or a custom docker
network with a pinned subnet (DAppNode's SDK validates this: non-`dncore_network` names in a
compose file's `networks:` are rejected outright), so `gnosisvpn-server` never reaches the
DAppNode host's own network namespace, only `node`'s (see above). That rules out explicit
host firewall exceptions: a host-level deployment punches two rules through the host's
DAppNode host's own network namespace, only its own container namespace on `dncore_network`
(see above). That rules out explicit host firewall exceptions: a host-level deployment
punches two rules through the host's
`DOCKER-USER` iptables chain - one accepting `wggvpn -> <public NIC>` traffic, and one
accepting the return leg (`<public NIC> -> wggvpn`) but only for `RELATED,ESTABLISHED`
connections, deliberately not `NEW`, so the exit node can't be used to originate unsolicited
Expand All @@ -35,19 +43,27 @@ packets to it in the first place. The same effect - only established/related tra
reaches `wggvpn` - is achieved structurally, by not being reachable, rather than by an
explicit firewall rule.

For the same reason, this exit node's internal `gnosisvpn-server` address (`127.0.0.1`) is
**not** the `172.30.0.1` a production, Ansible-managed exit node presents (a fixed docker
bridge gateway, identical across that whole fleet because every host pins the same subnet -
something this package structurally can't do). A `gnosis_vpn-client` connecting to *this*
exit node needs its `[connection.bridge]`/`[connection.wg]` `target` overridden to
`127.0.0.1:8000`/`127.0.0.1:51820` accordingly; since that's a single global client setting
(not per-destination), a client configured that way can no longer also reach a standard
`172.30.0.1`-convention exit node without changing it back.
For the same reason, this exit node's internal `gnosisvpn-server` address is **not** the
fixed `172.30.0.1` a production, Ansible-managed exit node presents (a fixed docker bridge
gateway, identical across that whole fleet because every host pins the same subnet -
something this package structurally can't do). Worse, unlike a first attempt at working
around this (sharing `node`'s network namespace to get a fixed loopback address - see git
history), the address DAppNode assigns `gnosisvpn-server` on `dncore_network` is **not fixed
across installs either**: it's a normal dynamic bridge IP, different on every install and not
guaranteed stable across a `gnosisvpn-server` recreate on the same install (though it survives
an ordinary restart). `entrypoint.sh` logs it on every `node` boot
(`gnosisvpn-server resolved to ...`), so it's discoverable per-install from DAppNode's log
viewer, but there's currently no single documented value a `gnosis_vpn-client` can be
pre-configured with the way `172.30.0.1` works for the production fleet - a client would need
to be told this specific exit node's current address out of band. The proper fix is upstream,
in `hoprd`: an in-progress change to have `hoprd` inform a connecting client of the correct
target itself, so clients don't need this hardcoded at all. Until that lands, this is a real
gap for this package's exit nodes specifically.

## Local testing

See [`test/README.md`](test/README.md) - `just local-up`/`local-down` runs the real
docker-compose stack outside DAppNode, and `just validate-config` checks `hoprd.cfg.yaml`
docker-compose stack outside DAppNode, and `just validate-config` checks `hoprd.cfg.yaml.tpl`
against the real hoprd binary's config parser.

## Building the package
Expand Down
1 change: 1 addition & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
HOPRD_PASSWORD: ${HOPRD_PASSWORD}
HOPRD_API_TOKEN: ${HOPRD_API_TOKEN}
HOPRD_HOST: ${HOPRD_HOST:-127.0.0.1:9091}
GNOSISVPN_SERVER_HOST: gnosisvpn-server
gnosisvpn-server:
environment:
WIREGUARD_CIDR: ${WIREGUARD_CIDR:-10.128.0.0/24}
18 changes: 9 additions & 9 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,22 @@ services:
HOPRD_MODULE_ADDRESS: "0x0000000000000000000000000000000000000000"
HOPRD_PASSWORD: ""
HOPRD_API_TOKEN: ""
# Set this to override the value in hoprd.cfg.yaml
# Set this to override the value in hoprd.cfg.yaml.tpl
HOPRD_BLOKLI_URL: ""
# Not exposed by `hoprd --help` in 4.0.3, but still read directly from the
# environment rather than as a registered CLI flag.
HOPRD_NAT: "true"
ADDITIONAL_CMDLINE_ARGS: ""
ADDITIONAL_ENVIRONMENT_VARS: ""
# DAppNode's dappmanager forces every service (this one included) onto dncore_network
# with a DNS alias of <serviceName>.<shortDnpName>.dappnode - see getShortUniqueDnp in
# DNP_DAPPMANAGER. hoprd's session_ip_forwarding needs a literal ip:port (hostnames are
# rejected by hoprd's own config parser), so entrypoint.sh resolves this at boot and
# renders it into hoprd.cfg.yaml.tpl - see entrypoint.sh and README.
GNOSISVPN_SERVER_HOST: "gnosisvpn-server.gnosisvpn-exit-node.public.dappnode"
restart: unless-stopped
depends_on:
- gnosisvpn-server
deploy:
resources:
limits:
Expand All @@ -50,14 +58,6 @@ services:
# Do not set image field here, it will be set automatically by the sdk.
build:
context: ./gnosisvpn-server
depends_on:
- node
# Shares node's network namespace instead of getting its own: hoprd's
# session_ip_forwarding needs a literal, stable ip:port for gnosisvpn-server, and this
# package can't pin gnosisvpn-server a static IP on a custom docker network (not allowed
# for non-core DAppNode packages - see README). Loopback is stable by construction, no
# per-boot resolution needed - see hoprd.cfg.yaml.
network_mode: "service:node"
cap_add:
- NET_ADMIN
volumes:
Expand Down
44 changes: 38 additions & 6 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,54 @@
set -euo pipefail

# hoprd's --blokliUrl has strict URL validation that rejects an explicit empty string (it
# doesn't treat "" as "not set") - unset it so the value already in hoprd.cfg.yaml wins,
# doesn't treat "" as "not set") - unset it so the value already in hoprd.cfg.yaml.tpl wins,
# unless the setup wizard actually provided one.
if [ -z "${HOPRD_BLOKLI_URL:-}" ]; then
unset HOPRD_BLOKLI_URL
fi

CUSTOM_CONFIG=/app/hoprd/conf/hoprd.custom.cfg.yaml
STATIC_CONFIG=/app/hoprd.cfg.yaml
TEMPLATE_CONFIG=/app/hoprd.cfg.yaml.tpl
RENDERED_CONFIG=/app/hoprd/conf/hoprd.generated.cfg.yaml

if [ -s "${CUSTOM_CONFIG}" ]; then
# A config was uploaded via the setup wizard's "Custom HOPR node configuration file"
# field - use it as-is, so we never clobber a deliberate override.
# field - use it as-is (modulo the same __GNOSISVPN_SERVER_IP__ substitution below), so we
# never clobber a deliberate override otherwise.
echo "entrypoint.sh: using uploaded ${CUSTOM_CONFIG}"
export HOPRD_CONFIGURATION_FILE_PATH="${CUSTOM_CONFIG}"
SOURCE_CONFIG="${CUSTOM_CONFIG}"
else
export HOPRD_CONFIGURATION_FILE_PATH="${STATIC_CONFIG}"
SOURCE_CONFIG="${TEMPLATE_CONFIG}"
fi

env ${ADDITIONAL_ENVIRONMENT_VARS} /bin/docker-entrypoint.sh ${ADDITIONAL_CMDLINE_ARGS}
: "${GNOSISVPN_SERVER_HOST:?GNOSISVPN_SERVER_HOST must be set}"
echo "entrypoint.sh: resolving gnosisvpn-server (${GNOSISVPN_SERVER_HOST})..."
gnosisvpn_server_ip=""
for _ in $(seq 1 30); do
gnosisvpn_server_ip="$(dig +short "${GNOSISVPN_SERVER_HOST}" A | head -n1)"
[ -n "${gnosisvpn_server_ip}" ] && break
sleep 1
done
if [ -z "${gnosisvpn_server_ip}" ]; then
echo "entrypoint.sh: FATAL: could not resolve ${GNOSISVPN_SERVER_HOST} after 30s - is gnosisvpn-server running?" >&2
exit 1
fi
# Logged deliberately: this is currently the only way to find gnosisvpn-server's address,
# which a connecting gnosis_vpn-client needs as its session target - see README. hoprd is
# expected to gain a way to tell clients this directly (tracked upstream), making this
# per-install manual lookup unnecessary.
echo "entrypoint.sh: gnosisvpn-server resolved to ${gnosisvpn_server_ip}"

# __GNOSISVPN_SERVER_IP__ is substituted the same way regardless of which file above won -
# see setup-wizard.yml's "Custom HOPR node configuration file" field, which documents using
# the same placeholder in an uploaded config too, instead of requiring the uploader to
# hardcode an address that isn't fixed and that hoprd can't resolve itself.
# No `sed` in this image (it's a minimal coreutils/util-linux build, no busybox either) - do
# the substitution with bash builtins instead. The `; echo x` / `%x` dance preserves a
# trailing newline that a bare `$(cat ...)` would otherwise strip.
config_contents="$(cat "${SOURCE_CONFIG}"; echo x)"
config_contents="${config_contents%x}"
printf '%s' "${config_contents//__GNOSISVPN_SERVER_IP__/${gnosisvpn_server_ip}}" >"${RENDERED_CONFIG}"
export HOPRD_CONFIGURATION_FILE_PATH="${RENDERED_CONFIG}"

env ${ADDITIONAL_ENVIRONMENT_VARS:-} /bin/docker-entrypoint.sh ${ADDITIONAL_CMDLINE_ARGS:-}
9 changes: 5 additions & 4 deletions gnosisvpn-server/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ sed \

# Without network_mode: host (not available to regular DAppNode packages - see README),
# /proc/sys/net/ipv4/ip_forward is read-only from inside this container, so it can't be set
# here even with NET_ADMIN. In practice it's already 1 on any host capable of running
# Docker/DAppNode at all (dockerd itself needs it host-wide for container networking), and
# this container's network namespace - shared with the node service via docker-compose.yml's
# `network_mode: "service:node"` - inherits that as its default. This is just a sanity check.
# here even with NET_ADMIN. In practice it's already 1: new network namespaces inherit the
# current value from their parent netns at creation time (verified directly - not reset to a
# hard-coded default), and any host capable of running Docker at all already has it enabled
# for dockerd's own bridge networking. This is just a sanity check, unrelated to which
# network(s) this container is on.
current_ip_forward="$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo unknown)"
if [ "${current_ip_forward}" != "1" ]; then
echo "gnosisvpn-server: WARNING: net.ipv4.ip_forward is '${current_ip_forward}', not '1', and this container cannot set it itself. VPN client traffic will not reach the internet until it's enabled on the DAppNode host." >&2
Expand Down
16 changes: 8 additions & 8 deletions gnosisvpn-server/wggvpn.conf.tpl
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# Rendered by entrypoint.sh from this template. PostUp/PreDown do the NAT a WireGuard exit
# node needs, scoped down to what's possible for a regular (non-core) DAppNode package: no
# network_mode: host, so this container's network namespace (shared with the node service -
# see docker-compose.yml's `network_mode: "service:node"` on this service) never reaches the
# DAppNode host's. MASQUERADE targets "eth0" (that shared network namespace's interface on
# its DAppNode-managed network) rather than a real public NIC; Docker's own host-level NAT
# then carries that traffic the rest of the way out, same as it does for any other
# container's egress traffic. Explicit DOCKER-USER firewall exceptions aren't included for
# the same reason (no host namespace access) - see README's "Known limitations vs.
# production" section for why that's not actually a gap here.
# network_mode: host, so this container's own network namespace (on dncore_network, like any
# other DAppNode package service - see docker-compose.yml) never reaches the DAppNode host's.
# MASQUERADE targets "eth0" (this container's own interface on its DAppNode-managed network)
# rather than a real public NIC; Docker's own host-level NAT then carries that traffic the
# rest of the way out, same as it does for any other container's egress traffic. Explicit
# DOCKER-USER firewall exceptions aren't included for the same reason (no host namespace
# access) - see README's "Known limitations vs. production" section for why that's not
# actually a gap here.
[Interface]
Address = __WG_ADDRESS__/32
ListenPort = 51820
Expand Down
14 changes: 7 additions & 7 deletions hoprd.cfg.yaml → hoprd.cfg.yaml.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ blokli_url: https://blokli-jura.prod.hoprnet.link
session_ip_forwarding:
target_allow_list:
# session_ip_forwarding requires a literal ip:port (hostnames are rejected by hoprd's own
# config parser). This package can't pin gnosisvpn-server a static IP on its own docker
# network (no custom docker networks allowed for non-core DAppNode packages - see
# README), so instead gnosisvpn-server shares this container's network namespace
# (docker-compose.yml's `network_mode: "service:node"`) and is reached over loopback -
# a fixed address that needs no per-boot resolution.
- "127.0.0.1:51820" # gnosisvpn-server's WireGuard interface
- "127.0.0.1:8000" # gnosisvpn-server's control endpoint
# config parser). This package can't pin gnosisvpn-server a static IP on dncore_network
# (not allowed for non-core DAppNode packages - see README), so entrypoint.sh resolves
# gnosisvpn-server's DAppNode DNS alias to its current IP at boot and renders this
# template (see GNOSISVPN_SERVER_HOST in docker-compose.yml) - this file is not used
# directly, only /app/hoprd/conf/hoprd.generated.cfg.yaml, its rendered output.
- "__GNOSISVPN_SERVER_IP__:51820" # gnosisvpn-server's WireGuard interface
- "__GNOSISVPN_SERVER_IP__:8000" # gnosisvpn-server's control endpoint
strategy:
allow_recursive: false
execution_interval: 15s
Expand Down
14 changes: 10 additions & 4 deletions setup-wizard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ fields:
description: |-
URL of the Blokli provider used by the node to connect to the blockchain.

Leave empty to use the value already shipped in `hoprd.cfg.yaml` (the same one used by
Leave empty to use the value already shipped in `hoprd.cfg.yaml.tpl` (the same one used by
HOPR Association's own GnosisVPN exit-node fleet). Only set this if you need to point
the node at a different Blokli endpoint.
secret: false
Expand All @@ -50,9 +50,15 @@ fields:
required: false
description: |-
Upload a custom configuration file for the HOPR node, replacing the shipped
`hoprd.cfg.yaml` (jura-prod GnosisVPN exit-node fleet defaults) entirely - including its
`session_ip_forwarding` entries, which you'd then need to point at gnosisvpn-server
yourself. Uploading here takes priority over the auto-generated config on every start.
`hoprd.cfg.yaml.tpl` (jura-prod GnosisVPN exit-node fleet defaults) entirely - including
its `session_ip_forwarding` entries. gnosisvpn-server's address isn't fixed (it can
differ per install and across a gnosisvpn-server recreate) and hoprd can't resolve it
itself (its config parser requires a literal ip:port, not a hostname), so if you
reference gnosisvpn-server here, use the literal placeholder `__GNOSISVPN_SERVER_IP__`
in place of its address (e.g. `__GNOSISVPN_SERVER_IP__:51820`) - the node substitutes
it with gnosisvpn-server's actual current address on every start, the same way it does
for the shipped config. Uploading here takes priority over the auto-generated config on
every start.
secret: false

- id: HOPRD_SAFE_ADDRESS
Expand Down
Loading
Loading