[GH-ISSUE #2808] Self-hosted: optional per-config update endpoint for in-app fleet failover/rotation #1280

Open
opened 2026-10-13 20:39:23 +00:00 by bsakaguchi · 3 comments
Owner

Originally created by @wintergenius on GitHub (Jul 6, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/2808

Summary

An optional, opt-in improvement for self-hosted users: let a SelfHosted config include the operator's own update endpoint together with a pinned public key, so the client can periodically pull a refreshed config from it. This gives self-hosters automatic failover / rotation across their own fleet of servers. It is purely additive and off by default.

Problem

Self-hosting is great, but a single server occasionally goes down — the hosting provider, a block, or just a crash. So people who self-host usually run a small fleet of servers rather than one. Today the app only understands static configs: when a server goes down, the user has to manually switch between separate configs. There is no way to do failover or rotation across your own infrastructure from inside the app.

Proposal

Add an optional field to a self-hosted config: the operator's update endpoint plus a pinned public key. When those fields are present, the client periodically fetches a refreshed config from that endpoint, verifies the response signature against the pinned key, and applies it (server list / active server / DNS, etc.). Properties:

  • opt-in — only active when the imported config includes these fields;
  • scoped — only for user-imported SelfHosted configs;
  • verified — the response must be signed by the key pinned at import time, so a config can only ever update itself from the operator who issued it.

In practice the operator (the self-hoster) runs a tiny endpoint that returns the current best config for their fleet, and the app follows it automatically instead of the user re-importing configs by hand.

It fits the existing code well

(paths/lines as of tag 4.8.19.0)

  • The SelfHosted config type and a per-config api_endpoint field already exist — apiDefs.h (ConfigType) and apiEndpoint key.
  • The client already knows how to fetch a config from a remote source and apply it (containers / hostName / DNS) — fillServerConfig().
  • The request base URL for those fetches is a single settable value, not hardcoded at the call sites — Settings::setGatewayEndpoint() — so the same fetch machinery can target a per-config endpoint.
  • The architecture already supports third-party operators distributing configs through the app — the external-premium / external-trial service types in apiUtils::getConfigType(). A self-hoster with a fleet is the same shape: they would just point at their own control plane.

Result

Multi-server self-hosting gets real, in-app failover and rotation across your own fleet, via a small, opt-in, signature-verified mechanism that reuses machinery already present in the client. Happy to help with a PR or a test config if the direction is interesting.


Reference server implementation (runnable, clean-room, MIT): https://github.com/wintergenius/Fleetcore — it speaks the exact wire format this proposal implies (GET /v1/config → an Ed25519-signed vpn://… config), so the whole loop can be run with docker run. Details in the comment below.

Originally created by @wintergenius on GitHub (Jul 6, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/2808 ## Summary An optional, opt-in improvement for self-hosted users: let a `SelfHosted` config include the operator's own update endpoint together with a pinned public key, so the client can periodically pull a refreshed config from it. This gives self-hosters automatic failover / rotation across their own fleet of servers. It is purely additive and off by default. ## Problem Self-hosting is great, but a single server occasionally goes down — the hosting provider, a block, or just a crash. So people who self-host usually run a small fleet of servers rather than one. Today the app only understands static configs: when a server goes down, the user has to manually switch between separate configs. There is no way to do failover or rotation across your own infrastructure from inside the app. ## Proposal Add an optional field to a self-hosted config: the operator's update endpoint plus a pinned public key. When those fields are present, the client periodically fetches a refreshed config from that endpoint, verifies the response signature against the pinned key, and applies it (server list / active server / DNS, etc.). Properties: - **opt-in** — only active when the imported config includes these fields; - **scoped** — only for user-imported `SelfHosted` configs; - **verified** — the response must be signed by the key pinned at import time, so a config can only ever update itself from the operator who issued it. In practice the operator (the self-hoster) runs a tiny endpoint that returns the current best config for their fleet, and the app follows it automatically instead of the user re-importing configs by hand. ## It fits the existing code well *(paths/lines as of tag 4.8.19.0)* - The `SelfHosted` config type and a per-config `api_endpoint` field already exist — [apiDefs.h (ConfigType)](https://github.com/amnezia-vpn/amnezia-client/blob/4.8.19.0/client/core/api/apiDefs.h#L8-L16) and [apiEndpoint key](https://github.com/amnezia-vpn/amnezia-client/blob/4.8.19.0/client/core/api/apiDefs.h#L26). - The client already knows how to fetch a config from a remote source and apply it (containers / hostName / DNS) — [fillServerConfig()](https://github.com/amnezia-vpn/amnezia-client/blob/4.8.19.0/client/ui/controllers/api/apiConfigsController.cpp#L159). - The request base URL for those fetches is a single settable value, not hardcoded at the call sites — [Settings::setGatewayEndpoint()](https://github.com/amnezia-vpn/amnezia-client/blob/4.8.19.0/client/settings.cpp#L505) — so the same fetch machinery can target a per-config endpoint. - The architecture already supports third-party operators distributing configs through the app — the external-premium / external-trial service types in [apiUtils::getConfigType()](https://github.com/amnezia-vpn/amnezia-client/blob/4.8.19.0/client/core/api/apiUtils.cpp#L85-L124). A self-hoster with a fleet is the same shape: they would just point at their own control plane. ## Result Multi-server self-hosting gets real, in-app failover and rotation across your own fleet, via a small, opt-in, signature-verified mechanism that reuses machinery already present in the client. Happy to help with a PR or a test config if the direction is interesting. --- **Reference server implementation** (runnable, clean-room, MIT): **https://github.com/wintergenius/Fleetcore** — it speaks the exact wire format this proposal implies (`GET /v1/config` → an Ed25519-signed `vpn://…` config), so the whole loop can be run with `docker run`. Details in the comment below.
Author
Owner

@wintergenius commented on GitHub (Jul 6, 2026):

I built a small, runnable server-side reference for this proposal, in case it's
useful to pin down the wire format and let you try the whole loop end-to-end:

https://github.com/wintergenius/Fleetcore (clean-room, Go, MIT)

It's the server half only — it stays product-neutral about the client change this
issue asks for (honor an update endpoint + pinned key on SelfHosted configs). It
never touches VPN traffic; it only serves signed config metadata (which of the
operator's own servers to use), so self-hosters get in-app failover/rotation across
their fleet.

The endpoint it speaks

  • GET /v1/config → { "config": "vpn://…", "sig": "<base64std ed25519>", "alg": "ed25519", "kid": "…", "ts": … }
  • GET /v1/pubkey → ed25519:<base64std> (the key an operator embeds when issuing a config)

Interop details (checked against amnezia-client 4.8.19.0, fillServerConfig):

  • the config payload is base64url without padding (matches Base64UrlEncoding | OmitTrailingEquals);
  • the signature is over the exact config string including the vpn:// prefix;
  • sig/alg/kid/ts are extra top-level fields, so the response already parses on
    today's unmodified client — a signature-aware client just additionally checks sig.
    The client change stays strictly additive.

Try it (no real servers needed for the demo):

docker compose -f deploy/docker-compose.yml run --rm --no-deps \
  --entrypoint /usr/local/bin/fleetcore fleetcore keygen -o /keys/ed25519.key
docker compose -f deploy/docker-compose.yml up --build
curl -s localhost:8443/v1/config | jq .

There's a ~40-line client-stub acceptance test that plays the proposed client
(verify signature → strip vpn:// → base64url-decode → parse), plus the full wire-key
reference and crypto spec in DESIGN.md. Happy to adjust names/shape to whatever the
client side prefers — the repo just fixes one concrete, interoperable contract to react to.

<!-- gh-comment-id:4898096350 --> @wintergenius commented on GitHub (Jul 6, 2026): I built a small, runnable **server-side reference** for this proposal, in case it's useful to pin down the wire format and let you try the whole loop end-to-end: **https://github.com/wintergenius/Fleetcore** (clean-room, Go, MIT) It's the *server* half only — it stays product-neutral about the client change this issue asks for (honor an update endpoint + pinned key on `SelfHosted` configs). It never touches VPN traffic; it only serves signed *config metadata* (which of the operator's own servers to use), so self-hosters get in-app failover/rotation across their fleet. **The endpoint it speaks** - `GET /v1/config` → `{ "config": "vpn://…", "sig": "<base64std ed25519>", "alg": "ed25519", "kid": "…", "ts": … }` - `GET /v1/pubkey` → `ed25519:<base64std>` (the key an operator embeds when issuing a config) **Interop details** (checked against `amnezia-client` `4.8.19.0`, `fillServerConfig`): - the `config` payload is `base64url` **without padding** (matches `Base64UrlEncoding | OmitTrailingEquals`); - the signature is over the **exact `config` string including the `vpn://` prefix**; - `sig`/`alg`/`kid`/`ts` are extra top-level fields, so the response already parses on today's unmodified client — a signature-aware client just *additionally* checks `sig`. The client change stays strictly additive. **Try it** (no real servers needed for the demo): ```sh docker compose -f deploy/docker-compose.yml run --rm --no-deps \ --entrypoint /usr/local/bin/fleetcore fleetcore keygen -o /keys/ed25519.key docker compose -f deploy/docker-compose.yml up --build curl -s localhost:8443/v1/config | jq . ``` There's a ~40-line client-stub acceptance test that plays the proposed client (verify signature → strip `vpn://` → base64url-decode → parse), plus the full wire-key reference and crypto spec in `DESIGN.md`. Happy to adjust names/shape to whatever the client side prefers — the repo just fixes one concrete, interoperable contract to react to.
Author
Owner

@Vitalik93 commented on GitHub (Jul 7, 2026):

Good suggestion. +1.

<!-- gh-comment-id:4903103593 --> @Vitalik93 commented on GitHub (Jul 7, 2026): Good suggestion. +1.
Author
Owner

@MrWaip commented on GitHub (Oct 7, 2026):

Было бы круто иметь такую опцию, я устал 12-15 устройств обновлять, как только отвалился self-hosted и я его переношу

<!-- gh-comment-id:6047399914 --> @MrWaip commented on GitHub (Oct 7, 2026): Было бы круто иметь такую опцию, я устал 12-15 устройств обновлять, как только отвалился self-hosted и я его переношу
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amnezia-client#1280
No description provided.