Netmaker add-on: pull prebuilt image from Gitea registry

Home Assistant add-on that joins the instance to a Netmaker network via
netclient. Distributed as a repository add-on using a prebuilt arm64 image
(gitea.savant.io/homeassistant/netmaker) to avoid local buildx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Savant Coder
2026-07-21 14:05:20 -04:00
commit 2803cf6792
27 changed files with 638 additions and 0 deletions

39
netmaker/.README.j2 Normal file
View File

@ -0,0 +1,39 @@
# Home Assistant Community App: Netmaker
![Project Stage][project-stage-shield] ![Project Maintenance][maintenance-shield]
Join your Home Assistant instance to a Netmaker WireGuard network.
## About
Netmaker is a platform for creating and managing fast, secure WireGuard
networks. This app joins your Home Assistant instance to a Netmaker network
using the Netmaker client, `netclient`.
You provide an enrollment token from your Netmaker dashboard, and the app
registers your device and keeps the connection running in the background.
{% if channel == "edge" %}
## WARNING! THIS IS AN EDGE VERSION!
This Home Assistant Apps repository contains edge builds of apps.
Edge builds apps are based upon the latest development version.
- They may not work at all.
- They might stop working at any time.
- They could have a negative impact on your system.
{% endif %}
{% if channel == "beta" %}
## WARNING! THIS IS A BETA VERSION!
This Home Assistant Apps repository contains beta releases of apps.
- They might stop working at any time.
- They could have a negative impact on your system.
{% endif %}
[maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg
[project-stage-shield]: https://img.shields.io/badge/project%20stage-experimental-yellow.svg
[release-shield]: https://img.shields.io/badge/version-{{ version }}-blue.svg
[release]: {{ repo }}/tree/{{ version }}

95
netmaker/DOCS.md Normal file
View File

@ -0,0 +1,95 @@
# Home Assistant Community App: Netmaker
Netmaker is a platform for creating and managing fast, secure WireGuard
networks. This app joins your Home Assistant instance to a Netmaker network
using the Netmaker client, `netclient`.
## Prerequisites
In order to use this app, you'll need access to a Netmaker server and an
**enrollment token** (also called an enrollment key) for the network you want
to join.
You can run your own Netmaker server or use a hosted one. See the Netmaker
documentation for details:
<https://docs.netmaker.io/>
## Installation
1. Install this app.
1. Generate an enrollment token in your Netmaker dashboard, under
**Enrollment Keys**.
1. Open the app's **Configuration** tab and paste the token into the
`enrollment_token` option.
1. Start the "Netmaker" app.
1. Check the logs of the "Netmaker" app to confirm it joined the network.
1. Done!
The host's WireGuard identity and keys are stored on the app's persistent
`/data` volume, so your device stays the same node across restarts and updates.
## Configuration
Example configuration:
```yaml
enrollment_token: "eyJ...your-token..."
log_level: info
host_name: ""
port: 0
interface: ""
endpoint: ""
endpoint6: ""
firewall: ""
```
### Option: `enrollment_token` (required)
The enrollment token used to join your Netmaker network. Generate one in your
Netmaker dashboard under **Enrollment Keys**. The token already contains the
address of your Netmaker server, so no separate server URL is needed.
### Option: `log_level`
Controls the verbosity of the netclient logs. One of `trace`, `debug`, `info`,
`notice`, `warning`, `error`, `fatal`. Use `debug` or `trace` when
troubleshooting connection problems.
### Option: `host_name`
Optional name to register this device as within your Netmaker network. Leave
empty to use the default host name.
### Option: `port`
Optional UDP port for WireGuard to listen on. Leave at `0` to let netclient
choose a port. If you set a specific port, expose the same port in the
**Network** section of the app.
### Option: `interface`
Optional name for the WireGuard network interface created on the host. On Linux
the name must start with `netmaker`. Leave empty for the default.
### Option: `endpoint` / `endpoint6`
Optional static public IPv4/IPv6 endpoint for this host. Leave empty to let
Netmaker detect the endpoint automatically.
### Option: `firewall`
Optional firewall backend to use for ACL enforcement: `iptables` or `nftables`.
Leave empty to let netclient auto-detect. If neither tool is available,
netclient runs without firewall/ACL enforcement (the connection still works).
## Network
This app runs on the host network. netclient uses WireGuard over UDP; by
default port `51821/udp` is used. If you configure a specific `port`, expose it
in the **Network** section.
## Leaving a network
To leave the Netmaker network, uninstall the app. To rotate the node's
identity, clear the app's data (uninstall and reinstall) before joining again.

63
netmaker/Dockerfile Normal file
View File

@ -0,0 +1,63 @@
ARG BUILD_FROM=ghcr.io/hassio-addons/base/amd64:10.1.1
# hadolint ignore=DL3006
FROM ${BUILD_FROM}
# Set shell
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Setup base
ARG BUILD_ARCH=amd64
ARG NETCLIENT_VERSION="v1.6.0"
# hadolint ignore=DL3018
RUN \
apk add --no-cache \
iproute2 \
iptables \
ip6tables \
nftables \
wireguard-tools \
openresolv \
coreutils \
\
&& ln -sf /sbin/xtables-nft-multi /sbin/ip6tables \
&& ln -sf /sbin/xtables-nft-multi /sbin/iptables \
\
&& if [ "${BUILD_ARCH}" = "aarch64" ]; then ARCH="arm64"; fi \
&& if [ "${BUILD_ARCH}" = "amd64" ]; then ARCH="amd64"; fi \
\
&& curl -L -s -o /opt/netclient \
"https://github.com/gravitl/netclient/releases/download/${NETCLIENT_VERSION}/netclient-linux-${ARCH}" \
&& chmod 0755 /opt/netclient \
&& ln -sf /opt/netclient /usr/bin/netclient
# Copy root filesystem
COPY rootfs /
HEALTHCHECK \
CMD healthcheck
# Build arguments
ARG BUILD_ARCH
ARG BUILD_DATE
ARG BUILD_DESCRIPTION
ARG BUILD_NAME
ARG BUILD_REF
ARG BUILD_REPOSITORY
ARG BUILD_VERSION
# Labels
LABEL \
io.hass.name="${BUILD_NAME}" \
io.hass.description="${BUILD_DESCRIPTION}" \
io.hass.arch="${BUILD_ARCH}" \
io.hass.type="addon" \
io.hass.version=${BUILD_VERSION} \
org.opencontainers.image.title="${BUILD_NAME}" \
org.opencontainers.image.description="${BUILD_DESCRIPTION}" \
org.opencontainers.image.vendor="Home Assistant Community Apps" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.source="https://github.com/${BUILD_REPOSITORY}" \
org.opencontainers.image.documentation="https://github.com/${BUILD_REPOSITORY}/blob/main/README.md" \
org.opencontainers.image.created=${BUILD_DATE} \
org.opencontainers.image.revision=${BUILD_REF} \
org.opencontainers.image.version=${BUILD_VERSION}

45
netmaker/apparmor.txt Normal file
View File

@ -0,0 +1,45 @@
#include <tunables/global>
profile netmaker flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
# Capabilities
file,
signal (send) set=(kill,term,int,hup,cont),
# S6-Overlay
/init ix,
/bin/** ix,
/usr/bin/** ix,
/run/{s6,s6-rc*,service}/** ix,
/package/** ix,
/command/** ix,
/etc/services.d/** rwix,
/etc/cont-init.d/** rwix,
/etc/cont-finish.d/** rwix,
/run/{,**} rwk,
/dev/tty rw,
# Bashio
/usr/lib/bashio/** ix,
/tmp/** rwk,
# Access to options.json and other files within your app
/data/** rw,
# Netclient configuration/state (symlinked to /data/netclient)
/etc/netclient/{,**} rw,
# General - capabilities derived from complain mode analysis
capability net_bind_service,
capability dac_override,
capability fsetid,
capability setgid,
capability setuid,
capability chown,
capability kill,
# General - based on Config.yaml
capability net_admin,
capability net_raw,
}

4
netmaker/build.yaml Normal file
View File

@ -0,0 +1,4 @@
---
build_from:
aarch64: ghcr.io/hassio-addons/base/aarch64:10.1.1
amd64: ghcr.io/hassio-addons/base/amd64:10.1.1

46
netmaker/config.yaml Normal file
View File

@ -0,0 +1,46 @@
---
name: Netmaker
version: dev
slug: netmaker
description: Join your Home Assistant instance to a Netmaker WireGuard network
url: https://github.com/hassio-addons/app-netmaker
image: gitea.savant.io/homeassistant/netmaker
startup: services
panel_icon: mdi:lan-connect
arch:
- aarch64
- amd64
init: false
hassio_api: true
host_network: true
privileged:
- NET_ADMIN
- NET_RAW
devices:
- /dev/net/tun
map:
- type: homeassistant_config
read_only: false
path: /config
- type: share
read_only: false
ports:
51821/udp: null
options:
enrollment_token: ""
log_level: info
host_name: ""
port: 0
interface: ""
endpoint: ""
endpoint6: ""
firewall: ""
schema:
enrollment_token: str
log_level: list(trace|debug|info|notice|warning|error|fatal)
host_name: str?
port: int(0,65535)?
interface: "match(^netmaker[a-zA-Z0-9_-]*$)?"
endpoint: str?
endpoint6: str?
firewall: list(iptables|nftables)?

View File

@ -0,0 +1,14 @@
#!/command/execlineb -S0
ifelse
{
importas -D 0 S6_KEEP_ENV S6_KEEP_ENV
eltest 0${S6_KEEP_ENV} -eq 0
}
{
s6-envdir -Lf -- /run/s6/container_environment
exec
$@
}
$@

View File

@ -0,0 +1,29 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
export LOG_FD
# ==============================================================================
# Wait for the local network (default HA interface) to be ready
# ==============================================================================
readonly WAIT_DELAY=5 # 5s
readonly WAIT_COUNT=60 # 60*5s = 300s = 5m
declare wait_counter=0
# Some services need a working local network to function properly.
# They can mark this service as dependency, and wait for the successful startup.
# Until HA has no default interface, we wait a little
while ! bashio::api.supervisor GET "/network/interface/default/info" false &> /dev/null; do
if (( wait_counter++ == WAIT_COUNT )); then
# We emit only a warning to let the app start, maybe this is the only connection to access the device, better to start than not.
# Let netclient figure out a way to connect to the network if the local network is temporarily down.
bashio::log.warning "Local network (default Home Assistant interface) is unreachable"
break
fi
bashio::log.info "Waiting for the local network (default Home Assistant interface) to be ready..."
sleep $WAIT_DELAY
done
if (( wait_counter != 0 && wait_counter <= WAIT_COUNT )); then
bashio::log.info "Local network is ready"
fi

View File

@ -0,0 +1 @@
oneshot

View File

@ -0,0 +1 @@
/etc/s6-overlay/s6-rc.d/local-network/run

View File

@ -0,0 +1,40 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
export LOG_FD
# ==============================================================================
# Home Assistant Community App: Netmaker
# Cleans up on daemon exit and takes down the S6 supervision tree on failure
# ==============================================================================
readonly exit_code_container=$(</run/s6-linux-init-container-results/exitcode)
readonly exit_code_service="${1}"
readonly exit_code_signal="${2}"
readonly service="netclient-daemon"
declare iface
bashio::log.info \
"Service ${service} exited with code ${exit_code_service}" \
"(by signal ${exit_code_signal})"
# Replicate the cleanup netclient's container entrypoint performs on shutdown.
# The daemon is exec'd, so its own shell trap does not survive; do it here.
iface=$(bashio::config 'interface')
if bashio::var.is_empty "${iface}"; then
iface="netmaker"
fi
ip rule delete pref 3000 2> /dev/null || true
ip rule delete pref 2500 2> /dev/null || true
ip rule delete pref 2000 2> /dev/null || true
ip link del "${iface}" 2> /dev/null || true
if [[ "${exit_code_service}" -eq 256 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo $((128 + $exit_code_signal)) > /run/s6-linux-init-container-results/exitcode
fi
[[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt
elif [[ "${exit_code_service}" -ne 0 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode
fi
exec /run/s6/basedir/bin/halt
fi

View File

@ -0,0 +1,39 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
export LOG_FD
# ==============================================================================
# Home Assistant Community App: Netmaker
# Runs the netclient daemon
# ==============================================================================
declare log_level
declare verbosity
bashio::log.info 'Starting Netmaker netclient...'
# Netclient stores its host identity, keys and node/server config under the
# hard-coded path /etc/netclient/. Persist it across restarts by pointing that
# path at the add-on's /data volume so the node is not re-registered each boot.
mkdir -p /data/netclient
if [ ! -L /etc/netclient ]; then
# Migrate anything an earlier image may have written to the real directory
if [ -d /etc/netclient ] && [ ! -d /data/netclient/.migrated ]; then
cp -a /etc/netclient/. /data/netclient/ 2>/dev/null || true
rm -rf /etc/netclient
fi
ln -sf /data/netclient /etc/netclient
fi
# Map the app's log_level to netclient's numeric verbosity (0..4)
log_level=$(bashio::config 'log_level')
case "${log_level}" in
trace) verbosity=4 ;;
debug) verbosity=3 ;;
info|notice) verbosity=2 ;;
warning) verbosity=1 ;;
error|fatal) verbosity=0 ;;
*) verbosity=2 ;;
esac
# Run the daemon in the foreground so s6 supervises it directly.
# functions.Daemon() blocks and handles SIGTERM (clean shutdown) / SIGHUP (reload).
exec /opt/netclient daemon -v "${verbosity}"

View File

@ -0,0 +1 @@
longrun

View File

@ -0,0 +1,68 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
export LOG_FD
# ==============================================================================
# Home Assistant Community App: Netmaker
# Joins the netmaker network once, using an enrollment token
# ==============================================================================
declare token
declare -a options=()
declare value
declare wait_counter=0
readonly NODES_FILE="/etc/netclient/nodes.json"
readonly WAIT_DELAY=2
readonly WAIT_COUNT=30 # 30*2s = 60s
# An enrollment token is required for a headless join. The token is a base64
# blob that already embeds the netmaker server address, so nothing else is
# needed to reach the server.
token=$(bashio::config 'enrollment_token')
if bashio::var.is_empty "${token}"; then
bashio::exit.nok \
"No 'enrollment_token' configured. Generate one in your Netmaker dashboard" \
"(Enrollment Keys) and set it in this app's configuration."
fi
# If this host is already registered to a network, don't join again. The daemon
# keeps nodes.json populated; a non-empty object means we have joined before.
if bashio::fs.file_exists "${NODES_FILE}" \
&& jq --exit-status 'length > 0' "${NODES_FILE}" > /dev/null 2>&1; then
bashio::log.info "netclient is already joined to a network, skipping join"
exit 0
fi
# Build the optional join flags from configuration.
if value=$(bashio::config 'host_name') && bashio::var.has_value "${value}"; then
options+=(-o "${value}")
fi
if bashio::config.has_value 'port' && [[ "$(bashio::config 'port')" -ne 0 ]]; then
options+=(-p "$(bashio::config 'port')")
fi
if value=$(bashio::config 'interface') && bashio::var.has_value "${value}"; then
options+=(-I "${value}")
fi
if value=$(bashio::config 'endpoint') && bashio::var.has_value "${value}"; then
options+=(-e "${value}")
fi
if value=$(bashio::config 'endpoint6') && bashio::var.has_value "${value}"; then
options+=(-E "${value}")
fi
if value=$(bashio::config 'firewall') && bashio::var.has_value "${value}"; then
options+=(-f "${value}")
fi
# Give the daemon a moment to initialize its config directory before joining.
while [ ! -d /etc/netclient ]; do
if (( wait_counter++ == WAIT_COUNT )); then
break
fi
sleep "${WAIT_DELAY}"
done
bashio::log.info "Joining netmaker network..."
if ! /opt/netclient join -t "${token}" "${options[@]}"; then
bashio::exit.nok "Failed to join the netmaker network"
fi
bashio::log.info "Successfully joined the netmaker network"

View File

@ -0,0 +1 @@
oneshot

View File

@ -0,0 +1 @@
/etc/s6-overlay/s6-rc.d/netclient-join/run

View File

@ -0,0 +1,35 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
export LOG_FD
# Netclient has no queryable status socket, so this treats the app as:
# - healthy while it is still trying to join (within HEALTHCHECK_RESTART_TIMEOUT)
# - healthy once it has joined a network and the daemon process is running
# - unhealthy if it never manages to join within HEALTHCHECK_RESTART_TIMEOUT
# Redirect outputs to the log (&1 and &2 are not initialized by Docker)
exec &> /proc/1/fd/1
if [[ "${LOG_FD:-}" =~ ^[0-9]+$ ]]; then
eval "exec ${LOG_FD}>&1" || true
fi
readonly HEALTHCHECK_RESTART_TIMEOUT=3600 # 1 hour
readonly NODES_FILE="/etc/netclient/nodes.json"
# STARTED_TIMESTAMP is stored in contenv at /var/run/s6/container_environment
if ! bashio::var.has_value "${STARTED_TIMESTAMP-}"; then
STARTED_TIMESTAMP=$(date +"%s")
printf "%s" "${STARTED_TIMESTAMP}" > /var/run/s6/container_environment/STARTED_TIMESTAMP
fi
# Healthy as soon as we are joined to at least one network.
if bashio::fs.file_exists "${NODES_FILE}" \
&& jq --exit-status 'length > 0' "${NODES_FILE}" > /dev/null 2>&1; then
exit 0
fi
# Not joined yet: stay healthy during the initial window, then fail so the
# container is restarted.
if (( $(date +"%s") - STARTED_TIMESTAMP > HEALTHCHECK_RESTART_TIMEOUT )); then
bashio::exit.nok "netclient has not joined a network within the expected time"
fi

View File

@ -0,0 +1,45 @@
---
configuration:
enrollment_token:
name: Enrollment token
description: >-
The enrollment token (enrollment key) used to join your Netmaker network.
Generate one in your Netmaker dashboard under "Enrollment Keys" and paste
it here. The token already contains the address of your Netmaker server.
log_level:
name: Log level
description: >-
Controls the verbosity of the netclient logs. Set to "debug" or "trace"
when troubleshooting connection problems.
host_name:
name: Host name
description: >-
Optional name to register this device as within your Netmaker network.
Leave empty to use the default host name.
port:
name: WireGuard port
description: >-
Optional UDP port for WireGuard to listen on. Leave at 0 to let netclient
choose a port. If you set this, expose the same port in the Network section.
interface:
name: Interface name
description: >-
Optional name for the WireGuard network interface created on the host.
On Linux the name must start with "netmaker". Leave empty for the default.
endpoint:
name: Static endpoint (IPv4)
description: >-
Optional static public IPv4 endpoint for this host. Leave empty to let
Netmaker detect the endpoint automatically.
endpoint6:
name: Static endpoint (IPv6)
description: >-
Optional static public IPv6 endpoint for this host. Leave empty to let
Netmaker detect the endpoint automatically.
firewall:
name: Firewall
description: >-
Optional firewall backend to use for ACL enforcement (iptables or
nftables). Leave empty to let netclient auto-detect.
network:
51821/udp: WireGuard port used by netclient