Pull request 174: feat vpn-libs-endpoint: improve docker support for trusttunnel

Squashed commit of the following:

commit 16a0c416f13fc32db3ae79ba7f0886ba3987f5aa
Author: Radmir Sadikov <r.sadikov@adguard.com>
Date:   Thu Mar 5 12:01:45 2026 +0400

    move healthcheck from Dockerfile to docker-compose.yml

commit 54aeb8d82462b7c8e7f5a70a744db2c8c8ad1e43
Author: Radmir Sadikov <r.sadikov@adguard.com>
Date:   Wed Mar 4 15:50:26 2026 +0400

    add port parameter for healthcheck

commit 0e375b9628a2ddcac9687d00068e9be7fa8a06a8
Author: Radmir Sadikov <r.sadikov@adguard.com>
Date:   Tue Mar 3 17:47:27 2026 +0400

    feat vpn-libs-endpoint: improve docker support for trusttunnel
This commit is contained in:
Radmir Sadikov 2026-03-05 11:29:36 +00:00
parent 18258db85f
commit ebb1ff4d3b
9 changed files with 191 additions and 19 deletions

View file

@ -87,6 +87,10 @@ The endpoint binary accepts the following command line arguments:
The main settings file contains core endpoint configuration. Example:
> Native deployments commonly use `listen_address = "0.0.0.0:443"`.
> If you run Docker with host-to-container mapping `443:8443`, use
> `listen_address = "0.0.0.0:8443"` inside `vpn.toml`.
```toml
# The address to listen on
listen_address = "0.0.0.0:443"

View file

@ -8,6 +8,7 @@
- [Cross-compiling for Linux](#cross-compiling-for-linux)
- [Usage](#usage)
- [Setup](#setup)
- [Run with External Configuration (CI/CD)](#run-with-external-configuration-cicd)
- [Customized Configuration](#customized-configuration)
- [See Also](#see-also)
@ -104,6 +105,38 @@ docker run -it trusttunnel-endpoint:latest --name trusttunnel-endpoint # Create
docker start -i trusttunnel-endpoint # If you need to start your VPN endpoint again
```
### Run with External Configuration (CI/CD)
If your CI/CD pipeline generates configuration files externally, you can run the container
without setup wizard interaction by mounting those files into the working directory volume.
Expected files under `/trusttunnel_endpoint`:
- `vpn.toml`
- `hosts.toml`
- `credentials.toml`
- Optional: `rules.toml`
- Optional: `certs/` (if certificate files are referenced from `hosts.toml`)
`docker-compose.yml` maps host HTTPS to a high port inside the container:
- Host `443/tcp` -> Container `8443/tcp`
- Host `443/udp` -> Container `8443/udp`
Therefore, make sure your `vpn.toml` listens on `0.0.0.0:8443`.
Start with compose:
```shell
docker compose up -d --build
```
Notes:
- No setup-related environment variables are required in this mode.
- Port `80:80` is only needed for in-container Let's Encrypt HTTP-01 issuance/renewal.
If certificates are provided externally, this mapping can be removed.
### Customized Configuration
For a more customized configuration experience, run the following commands:

View file

@ -11,6 +11,7 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- --defau
ENV PATH="/root/.cargo/bin:$PATH"
# Copy source files
WORKDIR $ENDPOINT_DIR_NAME
COPY deeplink/ ./deeplink
COPY endpoint/ ./endpoint
COPY lib/ ./lib
COPY macros/ ./macros
@ -21,14 +22,21 @@ RUN make endpoint/build
RUN make endpoint/build-wizard
# Copy binaries
FROM debian AS trusttunnel-endpoint
FROM debian:bookworm-slim AS trusttunnel-endpoint
ARG ENDPOINT_DIR_NAME="TrustTunnel"
ARG LOG_LEVEL="info"
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates iproute2 && rm -rf /var/lib/apt/lists/*
COPY --from=build /home/$ENDPOINT_DIR_NAME/target/release/setup_wizard /bin/
COPY --from=build /home/$ENDPOINT_DIR_NAME/target/release/trusttunnel_endpoint /bin/
COPY --chmod=755 /docker-entrypoint.sh /scripts/
WORKDIR /trusttunnel_endpoint
VOLUME /trusttunnel_endpoint/
ENTRYPOINT ["sh", "/scripts/docker-entrypoint.sh"]
# Persist endpoint state/configuration under this directory:
# - vpn.toml
# - hosts.toml
# - credentials.toml
# - rules.toml
# - certs/
VOLUME /trusttunnel_endpoint/
ENTRYPOINT ["/scripts/docker-entrypoint.sh"]

View file

@ -183,8 +183,8 @@ The wizard will ask for the following fields, some of them have the default
values you could safely use:
- **The address to listen on** - specify the address for the endpoint to listen
on. Use the default `0.0.0.0:443` if you want the endpoint to listen on port
443 (HTTPS) on all interfaces.
on. Use `0.0.0.0:443` for native deployments (HTTPS on all interfaces).
If you run with Docker port mapping `443:8443`, set it to `0.0.0.0:8443`.
- **Path to credentials file** - path where the user credentials for
authorization will be stored.
- **Username** - the username the user will use for authorization.

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
services:
trusttunnel-endpoint:
build:
context: .
target: trusttunnel-endpoint
image: trusttunnel-endpoint:local
container_name: trusttunnel-endpoint
restart: unless-stopped
ports:
- "443:8443/tcp"
- "443:8443/udp"
# Required for Let's Encrypt HTTP-01 challenge (TT_CERT_TYPE=letsencrypt).
# Remove this port if you do not issue/renew LE certificates in-container.
- "80:80/tcp"
environment:
# Override if endpoint listens on a non-default container port.
- TT_HEALTHCHECK_PORT=8443
healthcheck:
test: ["CMD-SHELL", "ss -ltn | grep -q ':${TT_HEALTHCHECK_PORT:-8443} '"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
volumes:
- trusttunnel_endpoint_data:/trusttunnel_endpoint
volumes:
trusttunnel_endpoint_data:
driver: local

View file

@ -1,5 +1,7 @@
#!/bin/bash
set -e
check_file() {
local file="$1"
if [ ! -f "$file" ]; then
@ -14,21 +16,73 @@ verify_configs() {
check_file "credentials.toml" || missing=1
check_file "vpn.toml" || missing=1
check_file "hosts.toml" || missing=1
return $missing
}
main() {
is_interactive=0
[ ! -t 0 ] || is_interactive=1
if [ ! verify_configs ] && [ ! -t 0 ]; then
echo "Missing configuration file(s). Run this containter in an interactive mode to complete setup."
exit 1
else
echo "Missing configuration file(s). Launching setup wizard."
setup_wizard
run_setup_wizard_noninteractive() {
if [ -z "${TT_HOSTNAME:-}" ] || [ -z "${TT_CREDENTIALS:-}" ]; then
echo "Error: TT_HOSTNAME and TT_CREDENTIALS are required for non-interactive setup"
return 1
fi
trusttunnel_endpoint vpn.toml hosts.toml
local args=(
"-m" "non-interactive"
"-a" "${TT_LISTEN_ADDRESS:-0.0.0.0:8443}"
"-c" "$TT_CREDENTIALS"
"-n" "$TT_HOSTNAME"
"--lib-settings" "vpn.toml"
"--hosts-settings" "hosts.toml"
)
case "${TT_CERT_TYPE:-self-signed}" in
self-signed)
args+=("--cert-type" "self-signed")
;;
letsencrypt)
if [ -z "${TT_ACME_EMAIL:-}" ]; then
echo "Error: TT_ACME_EMAIL is required when TT_CERT_TYPE=letsencrypt"
return 1
fi
args+=("--cert-type" "letsencrypt" "--acme-email" "$TT_ACME_EMAIL")
if [ "${TT_ACME_STAGING:-false}" = "true" ]; then
args+=("--acme-staging")
fi
;;
provided)
if [ -z "${TT_CERT_PROVIDED_CHAIN_PATH:-}" ] || [ -z "${TT_CERT_PROVIDED_KEY_PATH:-}" ]; then
echo "Error: TT_CERT_PROVIDED_CHAIN_PATH and TT_CERT_PROVIDED_KEY_PATH are required when TT_CERT_TYPE=provided"
return 1
fi
args+=(
"--cert-type" "provided"
"--cert-chain-path" "$TT_CERT_PROVIDED_CHAIN_PATH"
"--cert-key-path" "$TT_CERT_PROVIDED_KEY_PATH"
)
;;
*)
echo "Error: Unsupported TT_CERT_TYPE='$TT_CERT_TYPE'. Supported: self-signed, letsencrypt, provided"
return 1
;;
esac
echo "Missing configuration file(s). Running setup_wizard in non-interactive mode"
setup_wizard "${args[@]}"
}
main() {
if ! verify_configs; then
if [ -t 0 ]; then
echo "Missing configuration file(s). Launching setup wizard in interactive mode"
setup_wizard
else
run_setup_wizard_noninteractive
fi
fi
echo "Starting trusttunnel_endpoint"
exec trusttunnel_endpoint vpn.toml hosts.toml
}
main

View file

@ -27,7 +27,7 @@ pub fn build() -> Built {
.unwrap_or_else(|| {
ask_for_input(
&format!(
"{} (use 0.0.0.0:443 for all interfaces on HTTPS port)",
"{} (native: 0.0.0.0:443; Docker with 443:8443 mapping: 0.0.0.0:8443)",
Settings::doc_listen_address()
),
Some(Settings::default_listen_address().to_string()),

View file

@ -21,6 +21,8 @@ const HOSTNAME_PARAM_NAME: &str = "host";
const LIBRARY_SETTINGS_FILE_PARAM_NAME: &str = "lib_settings";
const TLS_HOSTS_SETTINGS_FILE_PARAM_NAME: &str = "hosts_settings";
const CERT_TYPE_PARAM_NAME: &str = "cert_type";
const CERT_CHAIN_PATH_PARAM_NAME: &str = "cert_chain_path";
const CERT_KEY_PATH_PARAM_NAME: &str = "cert_key_path";
const ACME_EMAIL_PARAM_NAME: &str = "acme_email";
const ACME_CHALLENGE_PARAM_NAME: &str = "acme_challenge";
const ACME_STAGING_PARAM_NAME: &str = "acme_staging";
@ -45,6 +47,8 @@ pub struct PredefinedParameters {
pub library_settings_file: Option<String>,
pub tls_hosts_settings_file: Option<String>,
pub cert_type: Option<String>,
pub cert_chain_path: Option<String>,
pub cert_key_path: Option<String>,
pub acme_email: Option<String>,
pub acme_challenge: Option<String>,
pub acme_staging: bool,
@ -141,8 +145,20 @@ Required in non-interactive mode."#,
clap::Arg::new(CERT_TYPE_PARAM_NAME)
.long("cert-type")
.action(clap::ArgAction::Set)
.value_parser(["self-signed", "letsencrypt"])
.help("Certificate type: 'self-signed' or 'letsencrypt'"),
.value_parser(["self-signed", "letsencrypt", "provided"])
.help("Certificate type: 'self-signed', 'letsencrypt', or 'provided'"),
clap::Arg::new(CERT_CHAIN_PATH_PARAM_NAME)
.long("cert-chain-path")
.action(clap::ArgAction::Set)
.value_parser(clap::builder::NonEmptyStringValueParser::new())
.required_if_eq(CERT_TYPE_PARAM_NAME, "provided")
.help("Path to provided certificate chain (required when --cert-type=provided)"),
clap::Arg::new(CERT_KEY_PATH_PARAM_NAME)
.long("cert-key-path")
.action(clap::ArgAction::Set)
.value_parser(clap::builder::NonEmptyStringValueParser::new())
.required_if_eq(CERT_TYPE_PARAM_NAME, "provided")
.help("Path to provided private key (required when --cert-type=provided)"),
clap::Arg::new(ACME_EMAIL_PARAM_NAME)
.long("acme-email")
.action(clap::ArgAction::Set)
@ -202,6 +218,8 @@ Required in non-interactive mode."#,
.get_one::<String>(TLS_HOSTS_SETTINGS_FILE_PARAM_NAME)
.cloned(),
cert_type: args.get_one::<String>(CERT_TYPE_PARAM_NAME).cloned(),
cert_chain_path: args.get_one::<String>(CERT_CHAIN_PATH_PARAM_NAME).cloned(),
cert_key_path: args.get_one::<String>(CERT_KEY_PATH_PARAM_NAME).cloned(),
acme_email: args.get_one::<String>(ACME_EMAIL_PARAM_NAME).cloned(),
acme_challenge: args.get_one::<String>(ACME_CHALLENGE_PARAM_NAME).cloned(),
acme_staging: args.get_flag(ACME_STAGING_PARAM_NAME),

View file

@ -47,6 +47,9 @@ pub fn build_with_runtime() -> Option<Cert> {
if crate::get_mode() == Mode::NonInteractive {
// Check if Let's Encrypt is requested via CLI
if let Some(ref cert_type) = crate::get_predefined_params().cert_type {
if cert_type == "provided" {
return load_provided_cert_noninteractive();
}
if cert_type == "letsencrypt" {
return generate_letsencrypt_cert_noninteractive();
}
@ -498,6 +501,29 @@ fn generate_letsencrypt_cert_noninteractive() -> Option<Cert> {
}
}
fn load_provided_cert_noninteractive() -> Option<Cert> {
let predefined = crate::get_predefined_params();
let cert_chain_path = predefined
.cert_chain_path
.clone()
.expect("Certificate chain path is required for provided cert type");
let cert_key_path = predefined
.cert_key_path
.clone()
.expect("Certificate key path is required for provided cert type");
drop(predefined);
let cert = parse_cert(Either::Right((&cert_chain_path, &cert_key_path)));
if cert.is_none() {
eprintln!(
"Failed to load provided certificate and key from '{}' and '{}'",
cert_chain_path, cert_key_path
);
}
cert
}
fn parse_cert_expiration(cert_pem: &str) -> Option<String> {
let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes()).ok()?;
let (_, cert) = x509_parser::parse_x509_certificate(&pem.contents).ok()?;