Skip to content

About

Traefik reverse proxy for Network Optimizer - solves HTTP/1.1 speed test problem with per-router ALPN

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

NetworkOptimizer-Proxy

A Traefik reverse proxy setup designed for Network Optimizer that solves the HTTP/1.1 speed test problem with a single proxy instance.

The Problem

OpenSpeedTest requires HTTP/1.1 for accurate throughput measurements - HTTP/2 multiplexing and flow control interfere with speed test results. Most reverse proxies (including Caddy) negotiate HTTP/2 at the TLS level and can't serve different protocols per hostname on the same port.

The Solution

Traefik supports per-router TLS options, including ALPN protocol selection. This setup uses a custom TLS option (h1only) that only advertises http/1.1 during the TLS handshake for the speed test hostname, while the main app uses the default HTTP/2 negotiation. One proxy, one IP, one port 443.

Quick Start (Docker)

git clone https://github.com/Ozark-Connect/NetworkOptimizer-Proxy.git
cd NetworkOptimizer-Proxy

# Run setup script (creates config files from examples, sets permissions)
bash setup.sh

# Edit your configuration
nano .env                    # Cloudflare token, email, listen IP
nano dynamic/config.yml      # Update hostnames

# Start
docker compose up -d

Windows (MSI)

Traefik is included as an optional feature in the Network Optimizer MSI installer. When selected, the installer prompts for Cloudflare DNS settings and the service manages Traefik as a child process alongside nginx.

The windows/ directory contains the config templates used by the MSI build:

  • traefik.yml.template - Static config with placeholders for registry values
  • config.yml.template - Dynamic config with placeholders for hostnames/ports

Requirements

  • For Linux: Docker and Docker Compose
  • For Windows: Network Optimizer MSI installer (Traefik feature)
  • A domain with DNS managed by Cloudflare (for automatic Let's Encrypt certificates)
  • DNS A records pointing to the host running Traefik:
    • e.g. optimizer.yourdomain.com - Network Optimizer web UI
    • e.g. speedtest.yourdomain.com - OpenSpeedTest (HTTP/1.1)
    • e.g. speedtest-wan.yourdomain.com - (optional) External WAN speed test server

Configuration

Environment Variables (.env)

Variable Required Default Description
ACME_EMAIL Yes - Email for Let's Encrypt registration
CF_DNS_API_TOKEN Yes - Cloudflare User API Token (created under My Profile > API Tokens) with Zone:Read and DNS:Edit permissions. Account API Tokens (cfat_ prefix) are not compatible — you need a User API Token (cfut_ prefix). It is a different token from the one Network Optimizer's Cloudflare Edge Blocking uses (an Account API Token with Account Rule Lists).
LISTEN_IP No 0.0.0.0 Bind to a specific IP address
DNS_RESOLVERS No 1.1.1.1:53,1.0.0.1:53 DNS servers for ACME DNS-01 validation. Override on hosts where the default resolvers are unreachable (e.g., 127.0.0.1:53 on a Pi-hole host that can't DNAT its own traffic to external DNS).
ACME_PROPAGATION_DELAY No 20 Seconds to wait after creating the ACME TXT record before asking Let's Encrypt to validate. Raise it (e.g. 120) if renewals intermittently fail with Incorrect TXT record / No TXT record found and then succeed on the next daily attempt.
FORWARDED_TRUSTED_IPS No none Comma-separated IPs/CIDRs of upstream proxies whose X-Forwarded-* headers Traefik keeps on the HTTPS entrypoint. Leave unset unless another proxy sits in front of Traefik. The WAF uses the same list. See Behind Cloudflare or another proxy.
LOG_LEVEL No INFO Log verbosity: DEBUG, INFO, WARN, ERROR
COMPOSE_PROFILES No none waf runs the bundled web application firewall. See Web Application Firewall.
WAF_MODE No detect The mode a new install starts in: detect logs attacks and lets them through; block answers 403. Once Network Optimizer switches the mode, the saved mode wins.
WAF_PARANOIA No 1 OWASP CRS paranoia level, 1-4. Higher catches more and flags more legitimate traffic. See Paranoia Level.
WAF_ALLOWED_METHODS No GET HEAD POST OPTIONS PUT PATCH DELETE HTTP methods the WAF lets through, separated by spaces. Any other method is blocked. CRS alone allows only GET, HEAD, POST, and OPTIONS, which would block the PUT, PATCH, and DELETE calls most web apps make. Add a method only if an app needs it, such as PROPFIND for WebDAV.
WAF_API_TOKEN No generated by setup.sh Shared secret for the WAF's API: Network Optimizer reads events and pushes its ban list and Trusted Sources with it.
WAF_LISTEN No 127.0.0.1:8044 WAF listen address. Change only if neither Network Optimizer nor the site's On-Site Agent runs on this host.

Dynamic Config (dynamic/config.yml)

Copy from the example and update hostnames:

cp config.example.yml dynamic/config.yml

The example config includes routers for:

  • optimizer - Network Optimizer on HTTP/2 (default TLS)
  • speedtest - OpenSpeedTest on HTTP/1.1 (h1only TLS option)
  • speedtest-wan - (optional, commented out) External WAN speed test server on HTTP/1.1

Edit the Host() rules to match your DNS:

optimizer:
  rule: "Host(`optimizer.yourdomain.com`)"
  # ...

speedtest:
  rule: "Host(`speedtest.yourdomain.com`)"
  # ...

Secrets (dynamic/secrets.yml)

Optional file for middleware that injects credentials (e.g., Basic Auth headers for backend services). Copy from example if needed:

cp secrets.example.yml dynamic/secrets.yml

This file is gitignored and managed directly on the host.

How It Works

HTTP/1.1 for Speed Tests

The h1only TLS option restricts ALPN negotiation to http/1.1 only:

tls:
  options:
    h1only:
      minVersion: VersionTLS12
      alpnProtocols:
        - "http/1.1"

When a browser connects to the speed test hostname, the TLS handshake negotiates HTTP/1.1 instead of HTTP/2. The speed test router references this option:

speedtest:
  rule: "Host(`speedtest.yourdomain.com`)"
  tls:
    options: h1only  # Forces HTTP/1.1 on the client connection

Speed Test Optimizations

In addition to HTTP/1.1, the speed test route strips the Accept-Encoding header to prevent transparent compression from skewing results.

Automatic HTTPS

Traefik uses Let's Encrypt with Cloudflare DNS-01 challenges, so:

  • No port 80 exposure required for certificate validation
  • Wildcard certificates are supported
  • Certificates auto-renew before expiry

By default, DNS propagation checking is disabled (propagation.disablechecks=true) and replaced with a fixed 20-second delay. This avoids certificate failures caused by local DNS resolvers (Pi-hole, NextDNS, AdGuard Home, etc.) that may not see the Cloudflare TXT records during validation. Cloudflare's API is usually fast enough that 20 seconds is plenty. If Let's Encrypt itself intermittently reports Incorrect TXT record or No TXT record found (the previous challenge value is still what Cloudflare's authoritative servers answer with), set ACME_PROPAGATION_DELAY in .env to a larger value such as 120.

WAN Speed Test Server (Optional)

If you deploy an external OpenSpeedTest server to a VPS for Client WAN Speed Testing, you can proxy it through this same Traefik instance. This gives you HTTPS (required for browser Private Network Access) and HTTP/1.1 (required for accurate results) without installing anything extra on the VPS.

  1. Deploy the speed test container on your VPS (see the Network Optimizer deployment guide)
  2. Add a DNS A record for speedtest-wan.yourdomain.com pointing to the host running Traefik (not the VPS)
  3. Uncomment the speedtest-wan router and service in dynamic/config.yml
  4. Update the service URL to point to your VPS: http://your-vps-hostname-or-ip:3005
  5. In Network Optimizer Settings, configure the External Speed Test Server with https scheme and the speedtest-wan.yourdomain.com hostname

Traffic flows: browser → Traefik (HTTPS/HTTP1.1) → VPS:3005 (HTTP). Traefik handles TLS termination and HTTP/1.1 enforcement. The VPS only needs port 3005 open and Docker.

Multi-Site (On-Site Agents) (Optional)

If you enable Network Optimizer's multi-site management, each remote site runs an on-site agent that dials home over a long-lived gRPC tunnel to this instance. The tunnel uses the same hostname as the app, split off by the gRPC service path (/networkoptimizer.agent.v1.AgentTunnel/), and connects to the app's HTTP/2 listener on port 8043. That listener serves TLS with an ephemeral self-signed cert, so the backend is https:// with verification skipped - this keeps the proxy-to-app hop encrypted even when Traefik runs on a separate box from the app.

This route ships enabled because it's a no-op without agents: the app binds the 8043 listener at startup, and nothing hits the gRPC path until an agent enrolls. To actually use it, turn on multi-site management in Network Optimizer. No app restart is needed (Network Optimizer v2.7.2 and later), and the agents router reuses your existing app hostname, so no new DNS record and no config edit are needed either.

Existing installs

setup.sh only copies config.example.yml on first run, so installs predating this route don't have it:

curl -fsSL https://raw.githubusercontent.com/Ozark-Connect/NetworkOptimizer-Proxy/main/add-agent-tunnel.sh | bash

No git checkout needed. Run it from your install directory, or from anywhere if that's /opt/traefik. It backs up config.yml first; append -s -- --dry-run to preview instead of writing.

Notes:

  • The websecure entrypoint ships with readTimeout: 0, which is required so the long-lived tunnel isn't severed at Traefik v3's default 60-second read deadline.
  • The insecureSkipVerify on the agent-tunnel-insecure serversTransport is expected: the tunnel listener's cert is a throwaway self-signed cert regenerated on every app start, so it can't be pinned. Confidentiality on the hop is the goal, not backend authentication.
  • A 502 on the gRPC path means Traefik cannot reach the app's 8043 listener. Check that the app is running and that nothing blocks the port between Traefik and the app. If the app log says Agent tunnel not bound, the app's ASPNETCORE_URLS has an HTTPS or non-port binding, and the listener cannot start alongside it.

Traffic flows: agent → Traefik (HTTPS/HTTP2) → app:8043 (self-signed TLS, HTTP/2 gRPC).

Behind Cloudflare or Another Proxy (Optional)

By default Traefik trusts no upstream proxy. It drops any incoming X-Forwarded-For / X-Forwarded-Host and sets them from the connection itself. That is correct when clients connect to Traefik directly.

When Traefik sits behind another proxy, such as Cloudflare's orange-cloud proxy, the connecting address is that proxy, not the client. List the proxy's addresses in .env so Traefik keeps the forwarded headers it adds:

For Cloudflare, .env.example carries the full list from cloudflare.com/ips, IPv4 and IPv6, ready to uncomment. To generate it fresh from Cloudflare's published lists:

echo "FORWARDED_TRUSTED_IPS=$( (curl -fsS https://www.cloudflare.com/ips-v4; echo; curl -fsS https://www.cloudflare.com/ips-v6) | grep . | paste -sd, -)"

Put the printed line in .env, then recreate the container (docker compose up -d). This is static config, so it does not hot-reload. To check it worked, look at the X-Forwarded-For your app logs for a request through Cloudflare: it should read <client>, <Cloudflare edge>. Traefik's own access log keeps showing the edge address as ClientHost either way.

Notes:

  • Anyone who can connect from a listed address chooses the client IP your app sees. For Cloudflare that is any Cloudflare customer, so also limit the origin to your own zone, e.g. with per-hostname Authenticated Origin Pulls. Firewalling to Cloudflare's ranges alone does not do this.
  • Read the client from the right, not the left. Cloudflare appends to an X-Forwarded-For the visitor already sent, so the left-most entry is whatever the client chose. The client is the right-most entry that is not a trusted proxy. The bundled WAF does this with the same list.
  • Cloudflare's ranges do not change often, and new ones are added to the published list before they go into production. Re-run the command above when you update. The API returns the same list for automation.
  • Network Optimizer applies its own trust setting on top: set TRUSTED_PROXIES and TRUSTED_PROXY_HOPS on the app (see its docker/.env.example) so it reads the client address through both hops.
  • Applies to the HTTPS entrypoint only. The HTTP entrypoint just redirects.
  • Docker only. The Windows and macOS static templates do not read this variable.

Web Application Firewall (Optional)

netopt-waf is a web application firewall built on Coraza and the OWASP Core Rule Set (CRS). Traefik asks it about each request on the routes you choose, through a forwardAuth middleware. It inspects the method, URI, headers, and body, and answers allow or block. Requests that cross the CRS anomaly threshold are kept for Network Optimizer, which shows them on Threat Intelligence next to the gateway's IPS events.

It complements UniFi CyberSecure rather than replacing it: the gateway's IPS sees packets, and only the proxy that terminates TLS sees the decrypted request.

  1. In .env, set COMPOSE_PROFILES=waf.
  2. Run bash setup.sh once to create waf-rules/before-crs.conf, waf-rules/after-crs.conf, and waf-rules/plugins/, and to generate WAF_API_TOKEN in .env if it is empty. Then docker compose up -d. The first start pulls the image (or builds it from waf/ when no release is published). Re-running it on an existing install is safe: it only creates what is missing and never replaces a token.
  3. Make sure dynamic/config.yml defines the waf middleware (copy it from config.example.yml on installs that predate it), then add - waf to a router's middlewares.
  4. In Network Optimizer, switch to the site this proxy serves and open Settings - Security & Alerts - Web Application Firewall (also under Threat Intelligence - Web Application Firewall - Setup). Enter the URL and the token (grep WAF_API_TOKEN .env). http://127.0.0.1:8044 works when the proxy runs on the same host as Network Optimizer, or on the same host as that site's On-Site Agent: a site reached through its agent has the agent make the connection. Each site can have its own WAF, or none.

It starts in detect mode: attacks are logged and let through. Run it that way for a while and add exclusions for anything legitimate it flags. After a week in detect, Network Optimizer's Threat Intelligence - Web Application Firewall page offers Switch to Block with the week's detections beside it, and Switch to Detection Only undoes it. The WAF saves the mode in its waf-state volume, so it survives a restart and overrides WAF_MODE, which only sets the mode a new install starts in.

Putting Your Own Apps Behind It

Any router can use the WAF: add - waf to its middlewares (see Adding More Services). Put it after any ipAllowList or auth middleware, so requests those refuse are never inspected. Opt in one app at a time and give each a detect-mode run of its own, since every app has its own false positives.

Good candidates are apps with forms, logins, and APIs: a CMS, a wiki, a dashboard, a password manager's web vault. Leave it off:

  • Speed tests and the Network Optimizer agents router: Traefik buffers the request body before asking the WAF, which breaks throughput tests and long-lived streams.
  • Routes whose clients send large files (photo and file sync, backups, media uploads), unless you give them the upload middleware below.

Large uploads. Traefik holds a request body in memory while the WAF decides, so each WAF middleware caps the body it takes: maxBodySize, 10 MB for waf. A request over the cap gets 401 from Traefik before the WAF sees it, so it never shows up as an event in Network Optimizer. Photo and file sync, backups, media uploads, and large PDF imports hit this. The way forward:

  • Docker: config.example.yml has a commented-out waf-uploads middleware with a 100 MB cap. Copy it into dynamic/config.yml, size the cap for your host, and list - waf-uploads in place of - waf on that router. Or raise maxBodySize on waf itself to lift it for every router.
  • macOS: raise maxBodySize in /usr/local/etc/traefik/dynamic/config.yml, or add the waf-uploads middleware there (the template carries it commented out).
  • Windows (MSI): the service regenerates Traefik\dynamic\config.yml from Traefik\templates\config.yml.template at every start, so edit the cap in the template, and expect an MSI upgrade to replace the template.

The WAF inspects the first WAF_BODY_LIMIT bytes (10 MB) of a body whatever the cap, so a higher cap costs memory and buys no more inspection. Set it as low as your uploads allow.

Only Your Sites (Trusted Sources)

For an app that only you and your other sites should reach, the WAF can replace a hand-kept ipAllowList. Network Optimizer pushes its Trusted Sources (every site's public IPv4 and IPv6 addresses, plus the extra addresses you add) to the WAF as the list trusted, and keeps it current as those addresses change. A router that uses the waf-trusted middleware from config.example.yml then takes only those addresses and LAN sources (RFC 1918, IPv6 ULA, loopback, link-local); everyone else gets 403, in either mode.

  • Use - waf-trusted in place of - waf, not beside it: it is the same inspection plus the list.
  • It is open to everyone until Network Optimizer pushes the list for the first time. After that the WAF keeps the last list in its waf-state volume, so a restart does not reopen anything.
  • Tailscale and other CGNAT addresses (100.64.0.0/10) are not LAN sources: add them to Trusted Sources' extra addresses.
  • Keep a plain ipAllowList for routes that should only ever be reached from the LAN.

Paranoia Level

WAF_PARANOIA sets the CRS paranoia level, 1 to 4. Each level adds stricter rules on top of the one below. It catches more attacks and flags more legitimate requests, and the false positives climb steeply at 3 and 4.

  • Level 1 (the default) is the CRS baseline, and suits most self-hosted apps.
  • Level 2 suits an app that guards sensitive data, once it runs clean in Block at level 1. Expect to add exclusions.
  • Levels 3 and 4 need app-by-app tuning to be usable.

To change it, edit WAF_PARANOIA in .env and run docker compose up -d netopt-waf. The WAF reads it only at start, and Network Optimizer cannot change it; its Web Application Firewall page shows the level in use. Before you raise the level on a WAF in Block, switch it to Detection Only. Then the new false positives show up as events, not as 403s. The Windows (MSI) WAF runs at level 1.

Exclusions and App Plugins

From Network Optimizer. On Threat Intelligence - Web Application Firewall - Events, a Site Admin can add an exclusion from the event itself: one CRS rule, turned off for a host, a method, and a path prefix. Network Optimizer sends it to the WAF's PUT /api/exclusions as fields, not rule text; the WAF validates each one, writes the SecLang, and rebuilds its rules in place, with no restart. A set that fails to build is refused and the running rules stay as they were. The WAF keeps the set in its waf-state volume, and Network Optimizer pushes it again on every sync, so a replaced WAF gets it back.

Built in. The WAF ships exclusion sets for self-hosted apps that have no CRS plugin. Apart from the two sets marked as on every router, a set applies only on a router that opts in, through its own middleware: a set matched by path alone would also exempt any other app behind the WAF that answers that path (WordPress runs every request through index.php), and a client cannot change which middleware Traefik calls.

  • Grafana (waf-grafana in config.example.yml, /check?app=grafana). Use it in place of - waf on the Grafana router. Panel, Explore, and alert-rule queries (Flux, InfluxQL, PromQL, LogQL, SQL) and ${variable} / $__macro templating read as code injection and XSS. The set turns off only the RCE, PHP injection, SQL injection, and XSS rule families, and only on the endpoints that carry queries: /api/ds/query, the data source proxy and resource paths, query history, alert rules, and dashboard and library panel saves. The paths match at the root or under one sub-path segment, such as /grafana/api/ds/query. Every other Grafana request, and every other rule family on those endpoints, is still inspected. Those endpoints accept queries by design, so keep Grafana patched: a WAF cannot tell a hostile query from a dashboard's.
  • Jellyfin (waf-jellyfin in config.example.yml, /check?app=jellyfin). Use it in place of - waf on the Jellyfin router. Without it, playback fails: the web client caps its bitrate at 2147483647 (INT_MAX), which CRS rule 942220 reads as an integer overflow attack, and /web/config.json trips the restricted file rule 930130. The set turns off 942220 on the whole router, because the value rides on playback, stream, and capability requests alike, and 930130 only on /web/config.json (at the root or under one base URL segment). Every other rule stays on.
  • Network Optimizer (on every router, no opt-in). When a browser cannot hold a WebSocket, Blazor falls back to Long Polling and posts to /_blazor as text/plain and application/octet-stream, which CRS rule 920420 refuses, so the page cannot connect. The set turns off 920420 on /_blazor only (at the root or under one base path segment). It is the one set that needs no middleware: no other app answers /_blazor, the rule it turns off is a content-type policy, and the body is still inspected.
  • JSON key names (on every router, no opt-in). The WAF names each JSON body key json.<key>, and the OS file list (CRS rule 930120) matches parts of those names, so ordinary keys such as profileId (Seerr's media requests), history, and bashrc read as file access. 930120 skips JSON key names only. JSON values, query string names, and every other rule are still checked.
  • Sonarr, Radarr, Lidarr, Prowlarr, Readarr, Whisparr (waf-arr in config.example.yml, /check?app=arr). Use it in place of - waf on each of those routers. Their web UI holds a SignalR connection whose client posts to /signalr/messages and /signalr/messages/negotiate as text/plain, which CRS rule 920420 refuses as a content type not allowed by policy, so every open browser tab raises events. The set turns off 920420 on those two paths (at the root or under one base URL segment). The API and everything else stay inspected.

By hand. Exclusions go in waf-rules/: runtime exclusions (ctl:ruleRemoveById, scoped to a host or path) in before-crs.conf, and rule removals (SecRuleRemoveById, SecRuleUpdateTargetById) in after-crs.conf. Restart netopt-waf after editing. Each event lists the CRS rule IDs that fired and, per rule, what it matched: the variable (ARGS, REQUEST_BODY, a header), the parameter or header name, and the matched value cut to 200 characters. The ID to exclude, and the reason it fired, are both in Network Optimizer's event detail.

For common apps, the CRS project publishes tested exclusion sets as plugins: WordPress, Nextcloud, Drupal, phpMyAdmin, DokuWiki, phpBB, XenForo, and cPanel. To add one, copy the .conf files from its plugins/ folder into waf-rules/plugins/ and restart netopt-waf. They load in the CRS plugin order: every *-config.conf, then *-before.conf, then the Core Rule Set, then *-after.conf.

An installed plugin applies to every host behind the WAF. To limit it to one app, edit its -config.conf and replace the commented-out rule with a Host check, for example for WordPress:

SecRule REQUEST_HEADERS:Host "!@streq blog.yourdomain.com" \
    "id:9507010,phase:1,pass,nolog,setvar:'tx.wordpress-rule-exclusions-plugin_enabled=0'"

Plugins that read .data files are not supported: only the .conf files are loaded.

Updating

The OWASP CRS rules are compiled into netopt-waf, so new rules arrive with a new netopt-waf release, never on their own. Each release names the CRS version it carries, and Network Optimizer's Web Application Firewall page shows the version each site's WAF runs and says when a newer one is out.

  • Docker: docker compose pull netopt-waf && docker compose up -d netopt-waf. The compose file follows the newest 1.x release (ghcr.io/ozark-connect/netopt-waf:1); set WAF_IMAGE_TAG in .env to pin one. Routers on the WAF answer 500 for the few seconds it restarts.
  • macOS: bash macos/update-waf.sh.
  • Windows: netopt-waf.exe ships in the Network Optimizer MSI and updates with it.

A new CRS release can flag requests the old one let through. Before updating a WAF that is in Block, switch it to Detection Only, update, and watch its events for a day before switching back. Exclusions, the mode, and the allow lists are kept across updates.

Updates are not automatic on purpose: a rule change should land when you can watch for what it blocks.

Notes

  • It fails closed. While netopt-waf is down, every router that lists the waf middleware answers 500. Add it to the routes you want protected, not to everything.
  • Behind Cloudflare, set FORWARDED_TRUSTED_IPS (see above). Without it every event shows a Cloudflare address. The WAF reads the same list and takes the right-most X-Forwarded-For entry that is not a trusted proxy, so a client cannot choose the address it is recorded under. Traefik's forwardAuth passes the WAF only the connecting address, which behind Cloudflare is the edge, so then the WAF reads Cloudflare's CF-Connecting-IP instead; it does that only when the connecting address is a trusted proxy.
  • The API (/api/events, /api/bans, /api/allowlists, /api/mode, /api/exclusions) needs WAF_API_TOKEN. Without it the WAF still filters, but Network Optimizer cannot read its events, push its lists and exclusions, or switch its mode.
  • Ban list. With Automatic Blocking on, Network Optimizer sends the addresses it has blocked to PUT /api/bans every minute, and the WAF answers 403 to them on every router that uses it, in either mode. The list lives in memory and each entry carries its own expiry, so a restart clears it until the next sync and bans lapse if Network Optimizer stops syncing. This is what blocks a client behind Cloudflare, where a gateway block cannot see the client's address. In Enforce, it also carries the public block lists chosen in Automatic Blocking.
  • The management API is plain HTTP. Keep WAF_LISTEN on 127.0.0.1 so the token never crosses a network. Network Optimizer reaches a remote site's WAF through that site's On-Site Agent, which connects to it locally. If Network Optimizer runs on another host with no agent beside the WAF, uncomment the waf-api router and service in config.example.yml and use https://waf.yourdomain.com as the WAF URL: Traefik terminates TLS, routes only /api/, and takes only LAN sources.
  • Response bodies are never inspected: forwardAuth sees only the request.
  • Windows (MSI): the Traefik feature ships netopt-waf.exe. Set the registry value TRAEFIK_WAF_MODE (string, detect or block; the starting mode) under HKLM\SOFTWARE\Ozark Connect\Network Optimizer and restart the Network Optimizer service. After that, switch modes from Network Optimizer; off still turns the WAF off. The service starts the WAF, puts it on the app's router, and connects Threat Intelligence to it. Exclusions go in Traefik\waf-rules\ in the install folder.
  • macOS (native): install the binary with bash macos/update-waf.sh (it downloads the release for your Mac and checks its SHA-256; or build it with cd waf && go build -o /usr/local/bin/netopt-waf .), copy macos/netopt-waf-wrapper.sh and a waf.env to /usr/local/etc/netopt-waf/, install macos/net.ozarkconnect.netopt-waf.plist in /Library/LaunchDaemons/ (a daemon, like Traefik's, so it runs from boot) and load it with sudo launchctl bootstrap system, and uncomment - waf on the optimizer router.

Adding More Services

To proxy additional services behind Traefik, add routers and services to dynamic/config.yml. Example:

http:
  routers:
    my-service:
      rule: "Host(`myservice.yourdomain.com`)"
      entryPoints:
        - websecure
      service: my-service
      tls:
        certResolver: letsencrypt
      middlewares:
        - security-headers
        # - waf          # optional: inspect requests with the bundled WAF

  services:
    my-service:
      loadBalancer:
        servers:
          - url: "http://localhost:9090"

To run a service behind the Web Application Firewall, uncomment - waf, or use - waf-uploads for an app that takes large uploads. Keep it after any allowlist or auth middleware.

IP Restrictions

Use the ipAllowList middleware to restrict access:

http:
  middlewares:
    lan-only:
      ipAllowList:
        sourceRange:
          - "192.168.0.0/16"
          - "10.0.0.0/8"

  routers:
    my-service:
      middlewares:
        - security-headers
        - lan-only

Architecture

Internet
    |
    v
[Traefik :443]
    |
    |-- optimizer.example.com (HTTP/2, default TLS)  -->  localhost:8042
    |
    |-- speedtest.example.com (HTTP/1.1, h1only TLS) -->  localhost:3005
    |
    |-- speedtest-wan.example.com (HTTP/1.1, h1only) -->  your-vps:3005  (optional)
    |
    |-- (other services...)
    |
[Traefik :80]
    |-- All HTTP --> 301 redirect to HTTPS
  • Single Traefik instance handles all hostnames
  • Host networking (network_mode: host) for direct access to local services
  • DNS-01 challenges via Cloudflare (no HTTP-01, no port 80 exposure needed)
  • File provider watches dynamic/ for config changes (hot reload, no restart needed)

Extracting Certificates

Traefik stores all Let's Encrypt certificates in acme/acme.json. To extract a certificate for use elsewhere (e.g., a UniFi gateway):

# Install traefik-certs-dumper (or use jq)
docker run --rm -v ./acme:/acme ldez/traefik-certs-dumper file \
  --source /acme/acme.json --dest /acme/certs \
  --domain-subdir

Troubleshooting

Certificates not issuing: Check that your Cloudflare API token is a User API Token (created under My Profile > API Tokens, cfut_ prefix) with Zone:Read and DNS:Edit permissions. Account API Tokens (cfat_ prefix) will not work. Verify the domain's DNS is managed by Cloudflare and check logs with docker compose logs. If you see NXDOMAIN or propagation timeout errors, your local DNS resolver may be interfering - the default config already handles this, but you can increase the delay by setting ACME_PROPAGATION_DELAY in .env if needed.

Speed test still using HTTP/2: Verify the speed test router references options: h1only in its TLS config. Check with: curl -v https://speedtest.yourdomain.com 2>&1 | grep ALPN.

Port conflict: If another service (e.g., Caddy) is already using port 443, set LISTEN_IP in .env to bind Traefik to a specific IP address.

500 on every request to a route: The route lists the waf middleware and netopt-waf is not running. Check with docker compose ps netopt-waf and docker compose logs netopt-waf.

401 on an upload to a route behind the WAF: the body is over that middleware's maxBodySize (10 MB for waf) and Traefik refused it before the WAF saw it, so there is no event. See Large uploads.

WAF blocking a legitimate request: Find its rule IDs in Network Optimizer's Threat Intelligence or in docker compose logs netopt-waf, and add an exclusion in waf-rules/ (see Web Application Firewall above).

License

MIT

About

Traefik reverse proxy for Network Optimizer - solves HTTP/1.1 speed test problem with per-router ALPN

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages