Commit 1ab59f36 authored by jan.koester's avatar jan.koester
Browse files

Initial commit: ProxyPlus architecture and prototype

Combined HTTP/SMTP proxy platform per the ProxyPlus mandate: libconfplus-backed
bootstrap config, authdb-backed auth, a binary transactional config store with
cluster replication over libparitypp, HTTP/SMTP proxy prototypes, a json-c REST
API, an AJAX admin UI, a versioned plugin API with Let's Encrypt/ZeroSSL ACME
plugins, and HTTP/SMTP load balancing.

See docs/ARCHITECTURE.md for the full design and every point where the real
library APIs diverge from the mandate's assumptions.
parents
Loading
Loading
Loading
Loading

.gitignore

0 → 100644
+4 −0
Changes for .gitignore: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
build/
*.o
*.so
*.a

CMakeLists.txt

0 → 100644
+107 −0
Changes for CMakeLists.txt: 107 added lines, 0 removed lines.
Original line number Diff line number Diff line
cmake_minimum_required(VERSION 3.16)
project(proxyplus VERSION 0.1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)

if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE RelWithDebInfo)
endif()

# --- Mandated libraries (see docs/ARCHITECTURE.md) ---
# libhttppp's own package config does not chain find_dependency() for its transitive deps,
# so netplus/cmdplus must be resolved before it (see libhttppp/CMakeLists.txt for precedent).
find_package(libnetplus REQUIRED)
find_package(libcmdplus REQUIRED)
find_package(libuuidp REQUIRED)
find_package(libconfplus REQUIRED)
find_package(libhttppp REQUIRED)
find_package(libsmtppp REQUIRED)
find_package(paritypp REQUIRED)
find_package(authdb REQUIRED)
find_package(json-c REQUIRED)

find_package(Threads REQUIRED)

# --- Optional: OpenDKIM / OpenDMARC (see docs/ARCHITECTURE.md §8) ---
find_library(OPENDKIM_LIB NAMES opendkim)
find_path(OPENDKIM_INCLUDE_DIR NAMES dkim.h)
find_library(OPENDMARC_LIB NAMES opendmarc)
find_path(OPENDMARC_INCLUDE_DIR NAMES dmarc.h)

if(OPENDKIM_LIB AND OPENDKIM_INCLUDE_DIR)
  message(STATUS "proxyplus: OpenDKIM found, building real DkimAdapter")
else()
  message(WARNING "proxyplus: libopendkim-dev not found -- DkimAdapter will be a no-op stub")
endif()
if(OPENDMARC_LIB AND OPENDMARC_INCLUDE_DIR)
  message(STATUS "proxyplus: OpenDMARC found, building real DmarcAdapter")
else()
  message(WARNING "proxyplus: libopendmarc-dev not found -- DmarcAdapter will be a no-op stub")
endif()

set(PROXYPLUS_CORE_SOURCES
    src/config/BootstrapConfig.cpp
    src/auth/AuthDBAdapter.cpp
    src/store/ConfigStore.cpp
    src/cluster/ClusterManager.cpp
    src/plugin/PluginManager.cpp
    src/http/HttpProxyServer.cpp
    src/smtp/SmtpProxyServer.cpp
    src/api/JsonApiServer.cpp
    src/security/DkimAdapter.cpp
    src/security/DmarcAdapter.cpp
    src/tls/CertificateManager.cpp
    src/lb/LoadBalancer.cpp
)

add_library(proxyplus_core STATIC ${PROXYPLUS_CORE_SOURCES})
target_compile_features(proxyplus_core PUBLIC cxx_std_20)
target_include_directories(proxyplus_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)

target_link_libraries(proxyplus_core PUBLIC
    authdb::client
    confplus::confplus
    httppp::httppp
    smtppp::smtppp
    paritypp::paritypp-static
    netplus::netplus
    cmdplus::cmdplus
    uuidp::uuidp
    json-c::json-c
    Threads::Threads
    ${CMAKE_DL_LIBS}
)

if(OPENDKIM_LIB AND OPENDKIM_INCLUDE_DIR)
  target_compile_definitions(proxyplus_core PRIVATE PROXYPLUS_WITH_OPENDKIM)
  target_include_directories(proxyplus_core PRIVATE ${OPENDKIM_INCLUDE_DIR})
  target_link_libraries(proxyplus_core PRIVATE ${OPENDKIM_LIB})
endif()
if(OPENDMARC_LIB AND OPENDMARC_INCLUDE_DIR)
  target_compile_definitions(proxyplus_core PRIVATE PROXYPLUS_WITH_OPENDMARC)
  target_include_directories(proxyplus_core PRIVATE ${OPENDMARC_INCLUDE_DIR})
  target_link_libraries(proxyplus_core PRIVATE ${OPENDMARC_LIB})
endif()

add_executable(proxyplus src/main.cpp)
target_link_libraries(proxyplus PRIVATE proxyplus_core)
target_compile_definitions(proxyplus PRIVATE
    PROXYPLUS_DEFAULT_STATIC_DIR="${CMAKE_INSTALL_PREFIX}/share/proxyplus/admin")

install(TARGETS proxyplus RUNTIME DESTINATION bin)
install(DIRECTORY admin/static/ DESTINATION share/proxyplus/admin)
install(FILES config/proxyplus.yaml DESTINATION share/proxyplus/examples)

add_executable(proxyplus-bootstrap tools/proxyplus-bootstrap.cpp)
target_link_libraries(proxyplus-bootstrap PRIVATE proxyplus_core)
install(TARGETS proxyplus-bootstrap RUNTIME DESTINATION bin)

add_subdirectory(plugins/example_dnsbl)
add_subdirectory(plugins/acme_common)
add_subdirectory(plugins/letsencrypt)
add_subdirectory(plugins/zerossl)

enable_testing()
add_subdirectory(test)

README.md

0 → 100644
+173 −0
Changes for README.md: 173 added lines, 0 removed lines.
Original line number Diff line number Diff line
# ProxyPlus

Combined HTTP/SMTP proxy platform. Start with [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) --
it defines exactly which data lives in `libconfplus`, which in `authdb`, and which in
ProxyPlus's own binary config store, and lists every point where the real APIs of the
mandated libraries fall short of what a naive reading of the spec would assume. This
prototype implements items 1-20 of the original mandate at "minimal working" depth: real
code against verified library APIs, not stubs pretending to be complete.

## Build

Requires (all found via `find_package`, all already packaged on this system):
`libconfplus-dev`, `libauthorize-dev` (ships `authdb`'s client headers), `libhttppp-dev`,
`libsmtppp-dev`, `libparitypp-dev`, `libnetplus-dev`, `libcmdplus-dev`, `libuuidplus-dev`,
`libjson-c-dev`. Optional: `libopendkim-dev`, `libopendmarc-dev` (see below).

```sh
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo ..
make -j"$(nproc)"
ctest --output-on-failure
```

### Known environment issue: rebuild libsmtppp before linking `proxyplus`

The system `libsmtppp1` package (built 2026-05-29) predates the currently installed
`libnetplus1` (2026-08-01+30) and was linked against an older, incompatible
`netplus::NetException::what()` ABI (it returned `std::string`; the current one returns
`const char*`). This makes the final `proxyplus` executable fail to link with:

```
undefined reference to `netplus::NetException::what[abi:cxx11]() const'
```

`proxyplus_core`, `proxyplus-bootstrap`, and all three unit tests build and link fine
regardless (they don't pull in the SMTP code path at link time) -- this only blocks the full
binary. Rebuild `libsmtppp` against the current `libnetplus` and reinstall the package
(same class of issue as previously hit with `libhtmlpp`/blogi):

```sh
cd ../../libsmtppp/build && cmake . && make -j"$(nproc)"
sudo make install   # or rebuild the .deb via the usual debian/ packaging and reinstall
```

This was verified: relinking `proxyplus` against a freshly built `libsmtppp.so` from that
tree succeeds with zero changes to ProxyPlus's own code.

### Optional: OpenDKIM / OpenDMARC

Neither `libopendkim-dev` nor `libopendmarc-dev` is installed in this environment.
`src/security/DkimAdapter.cpp` / `DmarcAdapter.cpp` are written against the real upstream C
APIs but compile to no-op stubs (`PROXYPLUS_WITH_OPENDKIM`/`PROXYPLUS_WITH_OPENDMARC` unset)
until those packages are present -- `cmake` auto-detects and reports which mode is active.

## First-time setup

1. **authdb** must already be running and reachable (ProxyPlus never runs its own user
   database, see docs/ARCHITECTURE.md §1). An authdb administrator must first create the
   `proxyplus.*` groups listed in [docs/ROLES.md](docs/ROLES.md) -- authdb's client API
   currently has no "create a group" call, so this one step cannot be automated from
   ProxyPlus's side.
2. Copy [config/proxyplus.yaml](config/proxyplus.yaml), adjust `paths.data`, `paths.authdb`,
   listener addresses/ports, and (for cluster mode) `cluster.bootstrap_nodes`.
3. Run `scripts/authdb-bootstrap.sh <authdb-url> <admin-username> <data-dir>` (needs an
   existing authdb admin user's password, prompted interactively). This creates ProxyPlus's
   own authdb service Client-Credential and the standard GPOs from docs/ROLES.md, and writes
   `<data-dir>/service-credential` -- **never commit this file**, it is ProxyPlus's own
   authentication secret and is deliberately kept out of the YAML config
   ("Keine Authentifizierungsgeheimnisse in YAML", per the mandate).
4. For TLS (admin API, cluster, SMTP): generate certificates with `netplus-certgen` (already
   installed on this system) into `<data-dir>/tls/<profile>.{crt,key}`, matching the
   `tls_profile` names referenced in the YAML -- or let Let's Encrypt do it, see below.
5. `proxyplus /etc/proxyplus/proxyplus.yaml`

## Let's Encrypt / ZeroSSL / ACME (automatic certificates)

`plugins/letsencrypt` and `plugins/zerossl` both implement a real ACME v2 (RFC 8555)
HTTP-01 client, sharing one protocol implementation in `plugins/acme_common` -- see
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) §6a for how it's built (netplus::rsa/hmac_sha256
for crypto, libhttppp for the wire calls, a hand-rolled PKCS#10 CSR builder) and its limits
(HTTP-01 only, no wildcards, RSA-2048 only). ZeroSSL additionally requires External Account
Binding on every account, which `plugins/zerossl` obtains automatically from ZeroSSL's free
`eab-credentials-email` API using just the profile's `acme_email` -- no ZeroSSL account or
API key needed.

1. Give an HTTP listener `port: 80` and no `tls_profile` (see the commented example in
   config/proxyplus.yaml) -- it must be reachable on plain HTTP for ACME's HTTP-01 challenge
   to work, on any listener, port 80 is the conventional choice.
2. Enable the profile via the JSON API (needs `tls.profiles.write`). `ca_plugin` selects
   `"letsencrypt"` or `"zerossl"` by name -- optional if only one of the two plugins is
   installed under `paths.plugins`, required if both are:
   ```sh
   curl -H "Authorization: Bearer <session>" -X POST https://admin-host:8443/api/v1/tls/profiles \
     -d '{"profile":"www","domains":["example.com","www.example.com"],
          "acme_enabled":true,"acme_email":"admin@example.com","ca_plugin":"letsencrypt"}'
   ```
3. Reference that same profile name as `tls_profile` on the HTTPS listener(s) that should use
   it (`http.listen[].tls_profile`, `admin.tls_profile`, ...).
4. `CertificateManager`'s background loop (checks every 6h, renews inside a 30-day window)
   picks it up automatically; `HttpD::reloadCertificates()` is called after each renewal so
   no restart is needed for HTTP/admin listeners (SMTP listeners are the one exception, see
   ARCHITECTURE.md §6a).

By default `plugins/letsencrypt` targets Let's Encrypt's **staging** directory (safe to
experiment against, browsers won't trust the result) -- pass `"acme_directory_url"` in the
same POST body to point at production (`https://acme-v02.api.letsencrypt.org/directory`)
once ready. `plugins/zerossl` always targets ZeroSSL's production directory (ZeroSSL has no
staging environment).

## Load balancing (HTTP/S and SMTP)

Both `HttpRouteRecord` and `SmtpRelayRuleRecord` can point at a named `UpstreamPoolRecord`
instead of a single backend -- see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) §6b for the
algorithms (round robin, weighted round robin, least connections, random, IP hash) and the
passive health-check behavior (no active prober in this prototype).

1. Create a pool (needs `lb.pools.write`):
   ```sh
   curl -H "Authorization: Bearer <session>" -X POST https://admin-host:8443/api/v1/pools \
     -d '{"id":"web-backends","kind":"http","algorithm":"least_connections",
          "targets":[{"address":"http://10.0.0.1:8080","weight":1},
                      {"address":"http://10.0.0.2:8080","weight":1}]}'
   ```
2. Reference it from a route or relay rule instead of the direct `upstream`/`smarthost`
   field:
   ```sh
   curl -H "Authorization: Bearer <session>" -X POST https://admin-host:8443/api/v1/http/routes \
     -d '{"id":"web","path_prefix":"/","upstream_pool":"web-backends"}'
   ```
3. `GET /api/v1/pools` (needs `lb.pools.read`) shows each target's live health/active-
   connection count alongside the static definition, for the admin dashboard.

## Runtime layout

- `admin/static/` -- the AJAX admin UI (installed to `share/proxyplus/admin`); talks only to
  the JSON API, never server-rendered.
- `plugins/example_dnsbl/` -- example plugin (SMTP connection-check via a DNSBL zone); build
  output goes wherever `paths.plugins` in the YAML points.
- `<data-dir>/proxyplus.wal` + `proxyplus.snapshot` -- the binary transactional config store
  (routes, relay rules, plugin config, permission registry). Never touch these by hand; use
  the JSON API (`/api/v1/http/routes`, `/api/v1/smtp/relays`, ...).
- `<data-dir>/parity/` -- this node's erasure-coded block-store shard (cluster mode only).
- `<data-dir>/cluster.key` -- auto-generated on first cluster-mode start, the shared HMAC
  secret nodes use to authenticate to each other's block store (see ARCHITECTURE.md §8 on
  why this isn't mTLS yet).

## Operating

- **Shutdown**: currently process-kill only (SIGTERM/SIGINT) -- no graceful in-flight-request
  drain is wired up yet; libhttppp/libsmtppp's event loops have no external stop() hook
  exposed to `HttpProxyServer`/`SmtpProxyServer` in this prototype.
- **Config changes**: bootstrap YAML changes require a restart (`BootstrapConfig::reload()`
  exists and re-validates, but nothing currently calls it on SIGHUP -- wiring that up is a
  small, self-contained follow-up). Dynamic config (routes, relay rules, plugins) changes
  live via the JSON API with no restart needed.
- **Cluster status**: `GET /api/v1/status` (needs `admin.dashboard.read`) reports authdb
  reachability, local config version, and -- in cluster mode -- quorum/node counts from
  `ClusterManager`.
- **Migration from a single node to a cluster**: set `node.mode: cluster`,
  `cluster.enabled: true`, and list every node's `cluster.listen_address:listen_port` under
  `cluster.bootstrap_nodes` on every node, then restart each. `ConfigStore` on whichever node
  currently has the highest snapshot version wins during the first sync round
  (last-writer-wins, see ARCHITECTURE.md §5) -- start from a single node with the config you
  want to keep, add peers one at a time, and check `/api/v1/status` after each.

## What's deliberately not implemented in this prototype

See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) §8 for the full list. In short: no MFA / no
login-rate-limiting / no account-lockout (all require authdb-side features that don't exist
yet), no mTLS (libnetplus has no client-cert verification), only GET is proxied by the HTTP
listener (POST/PUT relaying needs the inbound-body-to-outbound-request plumbing finished),
one smarthost per SMTP message, and no disk-backed mail queue/retry (a hard bounce on relay
failure today, though `paths.queue` is reserved for this).

admin/static/app.js

0 → 100644
+177 −0
Changes for admin/static/app.js: 177 added lines, 0 removed lines.
Original line number Diff line number Diff line
// Minimal AJAX admin UI for ProxyPlus. Talks only to the JSON REST API
// (src/api/JsonApiServer) -- no server-rendered HTML, matching the mandate's
// "AJAX-Admin-Oberflaeche" requirement. Session id is kept in memory only, sent back as a
// Bearer token (see docs/ARCHITECTURE.md §2: the bearer token *is* the authdb session id).

const ProxyPlusAdmin = (() => {
  let sessionId = null;

  async function api(method, path, body) {
    const headers = { "Content-Type": "application/json" };
    if (sessionId) headers["Authorization"] = "Bearer " + sessionId;
    const res = await fetch(path, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
    });
    const data = await res.json().catch(() => ({}));
    if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
    return data;
  }

  async function login() {
    const username = document.getElementById("login-username").value;
    const password = document.getElementById("login-password").value;
    document.getElementById("login-error").textContent = "";
    try {
      const data = await api("POST", "/api/v1/auth/login", { username, password });
      sessionId = data.session_id;
      document.getElementById("who").textContent = "Angemeldet als " + username;
      document.getElementById("login-panel").hidden = true;
      document.getElementById("dashboard").hidden = false;
      await refreshAll();
    } catch (e) {
      document.getElementById("login-error").textContent = e.message;
    }
  }

  function logout() {
    sessionId = null;
    document.getElementById("who").textContent = "";
    document.getElementById("dashboard").hidden = true;
    document.getElementById("login-panel").hidden = false;
  }

  async function refreshStatus() {
    const status = await api("GET", "/api/v1/status");
    const table = document.getElementById("status-table");
    table.innerHTML = "";
    const rows = [
      ["Node", status.node_id + " (" + status.mode + ")"],
      ["authdb erreichbar", status.authdb_reachable ? "ja" : "nein"],
      ["Konfigurationsversion", status.config_version],
      ["Cluster aktiv", status.cluster && status.cluster.enabled ? "ja" : "nein"],
    ];
    if (status.cluster && status.cluster.enabled) {
      rows.push(["Quorum erreichbar", status.cluster.quorum_reachable ? "ja" : "nein"]);
      rows.push(["Knoten online", status.cluster.nodes_online + " / " + status.cluster.nodes_total]);
    }
    for (const [label, value] of rows) {
      const tr = document.createElement("tr");
      tr.innerHTML = "<td>" + label + "</td><td>" + value + "</td>";
      table.appendChild(tr);
    }
  }

  async function refreshRoutes() {
    const routes = await api("GET", "/api/v1/http/routes");
    const body = document.querySelector("#http-routes tbody");
    body.innerHTML = "";
    for (const r of routes) {
      const tr = document.createElement("tr");
      tr.innerHTML = `<td>${r.id}</td><td>${r.path_prefix}</td><td>${r.upstream}</td><td>${r.upstream_pool}</td><td>${r.required_permission}</td>`;
      body.appendChild(tr);
    }
  }

  async function refreshRelays() {
    const relays = await api("GET", "/api/v1/smtp/relays");
    const body = document.querySelector("#smtp-relays tbody");
    body.innerHTML = "";
    for (const r of relays) {
      const tr = document.createElement("tr");
      tr.innerHTML = `<td>${r.id}</td><td>${r.domain}</td><td>${r.smarthost}</td><td>${r.smarthost_pool}</td><td>${r.owner_group_id}</td>`;
      body.appendChild(tr);
    }
  }

  async function refreshPools() {
    const pools = await api("GET", "/api/v1/pools");
    const body = document.querySelector("#lb-pools tbody");
    body.innerHTML = "";
    for (const p of pools) {
      const targets = (p.targets || [])
        .map((t) => `${t.address} (w=${t.weight}${t.healthy === undefined ? "" : t.healthy ? ", up" : ", DOWN"})`)
        .join(", ");
      const tr = document.createElement("tr");
      tr.innerHTML = `<td>${p.id}</td><td>${p.kind}</td><td>${p.algorithm}</td><td>${targets}</td>`;
      body.appendChild(tr);
    }
  }

  async function refreshAll() {
    await Promise.all([refreshStatus(), refreshRoutes(), refreshRelays(), refreshPools()]);
  }

  function showNewRouteForm() {
    document.getElementById("new-route-form").hidden = false;
  }

  async function createRoute() {
    await api("POST", "/api/v1/http/routes", {
      id: document.getElementById("route-id").value,
      path_prefix: document.getElementById("route-path").value,
      upstream: document.getElementById("route-upstream").value,
      upstream_pool: document.getElementById("route-upstream-pool").value,
      required_permission: document.getElementById("route-permission").value,
    });
    await refreshRoutes();
  }

  function showNewRelayForm() {
    document.getElementById("new-relay-form").hidden = false;
  }

  async function createRelay() {
    await api("POST", "/api/v1/smtp/relays", {
      id: document.getElementById("relay-id").value,
      domain: document.getElementById("relay-domain").value || "*",
      smarthost: document.getElementById("relay-smarthost").value,
      smarthost_pool: document.getElementById("relay-smarthost-pool").value,
      owner_group_id: document.getElementById("relay-group").value,
    });
    await refreshRelays();
  }

  function showNewPoolForm() {
    document.getElementById("new-pool-form").hidden = false;
  }

  // Parses "http://10.0.0.1:8080:1, http://10.0.0.2:8080:2" into
  // [{address:"http://10.0.0.1:8080", weight:1}, ...] -- split each entry on its *last*
  // colon so an "http://host:port" address's own colon isn't mistaken for the separator.
  function parseTargets(text) {
    return text
      .split(",")
      .map((s) => s.trim())
      .filter(Boolean)
      .map((entry) => {
        const sep = entry.lastIndexOf(":");
        if (sep <= 0) return { address: entry, weight: 1 };
        const weight = parseInt(entry.slice(sep + 1), 10);
        if (Number.isNaN(weight)) return { address: entry, weight: 1 };
        return { address: entry.slice(0, sep), weight };
      });
  }

  async function createPool() {
    await api("POST", "/api/v1/pools", {
      id: document.getElementById("pool-id").value,
      kind: document.getElementById("pool-kind").value,
      algorithm: document.getElementById("pool-algorithm").value,
      targets: parseTargets(document.getElementById("pool-targets").value),
    });
    await refreshPools();
  }

  return {
    login,
    logout,
    showNewRouteForm,
    createRoute,
    showNewRelayForm,
    createRelay,
    showNewPoolForm,
    createPool,
  };
})();
+72 −0
Changes for admin/static/index.html: 72 added lines, 0 removed lines.
Original line number Diff line number Diff line
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>ProxyPlus Admin</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
  <h1>ProxyPlus</h1>
  <div id="who"></div>
</header>

<section id="login-panel">
  <h2>Anmelden</h2>
  <input id="login-username" placeholder="Benutzername">
  <input id="login-password" type="password" placeholder="Passwort">
  <button onclick="ProxyPlusAdmin.login()">Login</button>
  <div id="login-error" class="error"></div>
</section>

<section id="dashboard" hidden>
  <h2>Status</h2>
  <table id="status-table"></table>

  <h2>HTTP-Routen</h2>
  <table id="http-routes"><thead><tr><th>ID</th><th>Pfad</th><th>Upstream</th><th>Pool</th><th>Berechtigung</th></tr></thead><tbody></tbody></table>
  <button onclick="ProxyPlusAdmin.showNewRouteForm()">Neue Route</button>
  <div id="new-route-form" hidden>
    <input id="route-id" placeholder="ID">
    <input id="route-path" placeholder="Pfad-Prefix, z.B. /app">
    <input id="route-upstream" placeholder="Upstream (direkt), z.B. http://10.0.0.5:8080">
    <input id="route-upstream-pool" placeholder="oder Pool-ID (statt Upstream)">
    <input id="route-permission" placeholder="Berechtigung, z.B. http.proxy.use">
    <button onclick="ProxyPlusAdmin.createRoute()">Anlegen</button>
  </div>

  <h2>SMTP-Relays</h2>
  <table id="smtp-relays"><thead><tr><th>ID</th><th>Domain</th><th>Smarthost</th><th>Pool</th><th>Owner-Gruppe</th></tr></thead><tbody></tbody></table>
  <button onclick="ProxyPlusAdmin.showNewRelayForm()">Neues Relay</button>
  <div id="new-relay-form" hidden>
    <input id="relay-id" placeholder="ID">
    <input id="relay-domain" placeholder="Domain, * fuer alle">
    <input id="relay-smarthost" placeholder="Smarthost (direkt), z.B. mail.example.com:587">
    <input id="relay-smarthost-pool" placeholder="oder Pool-ID (statt Smarthost)">
    <input id="relay-group" placeholder="Owner-Gruppen-UUID (optional)">
    <button onclick="ProxyPlusAdmin.createRelay()">Anlegen</button>
  </div>

  <h2>Load-Balancer-Pools</h2>
  <table id="lb-pools"><thead><tr><th>ID</th><th>Art</th><th>Algorithmus</th><th>Ziele (Status)</th></tr></thead><tbody></tbody></table>
  <button onclick="ProxyPlusAdmin.showNewPoolForm()">Neuer Pool</button>
  <div id="new-pool-form" hidden>
    <input id="pool-id" placeholder="ID">
    <select id="pool-kind"><option value="http">http</option><option value="smtp">smtp</option></select>
    <select id="pool-algorithm">
      <option value="round_robin">round_robin</option>
      <option value="weighted_round_robin">weighted_round_robin</option>
      <option value="least_connections">least_connections</option>
      <option value="random">random</option>
      <option value="ip_hash">ip_hash</option>
    </select>
    <input id="pool-targets" placeholder="Ziele: adresse:gewicht, ... z.B. http://10.0.0.1:8080:1, http://10.0.0.2:8080:1">
    <button onclick="ProxyPlusAdmin.createPool()">Anlegen</button>
  </div>

  <button onclick="ProxyPlusAdmin.logout()">Logout</button>
</section>

<script src="app.js"></script>
</body>
</html>
Loading