diff --git a/services/hermes/default.nix b/services/hermes/default.nix index 487c325..65e0e39 100644 --- a/services/hermes/default.nix +++ b/services/hermes/default.nix @@ -17,6 +17,15 @@ claudeCode = pkgs.claude-code.override { manifest = lib.importJSON ./claude-code-manifest.json; }; + + # MCP Pangolin (script ./pangolin-mcp.py) : reconstruit ici le binaire qui + # n'existait qu'en store path brut (jamais référencé par le flake, donc + # ramassable par un GC). Interpréteur = python3 + SDK `mcp` ; les appels + # HTTP vont vers l'API d'intégration Pangolin (flag enable_integration_api + # de services/pangolin) sur api.marcpartensky.com/v1. + pangolinMcp = pkgs.writers.writePython3Bin "pangolin-mcp" { + libraries = [pkgs.python3Packages.mcp]; + } (builtins.readFile ./pangolin-mcp.py); in { # Option : le token Matrix du bot @hermes (clé hermes_matrix_env de # secrets/hermes-matrix.yml). Les destinataires de ce fichier sops ne @@ -497,23 +506,23 @@ in { timeout = 60; }; - # --- MCP Pangolin : serveur auto-hébergé (pangolin.vps.marcpartensky.com) --- - # DÉSACTIVÉ TEMPORAIREMENT (29/09/2026) : sops.secrets."pangolin/api_key" - # ci-dessous n'a jamais eu de clé correspondante dans secrets/common.yml - # (nested pangolin.api_key absent), ce qui fait échouer TOUT - # nixos-rebuild switch sur tower ("the key 'pangolin' cannot be found"). - # Déjà noté comme inerte (clé/URL placeholder) avant ce blocage. Réactiver - # une fois la vraie clé posée par marc (il a la clé age privée pour éditer - # secrets/common.yml en place). - # services.hermes-agent.mcpServers.pangolin = { - # command = "/nix/store/pw5hn1g2icm9ll7li8qiy1wkpdzvkcs1-pangolin-mcp/bin/pangolin-mcp"; - # args = [ ]; - # env = { - # PANGOLIN_BASE_URL = "https://pangolin.vps.marcpartensky.com/v1"; - # PANGOLIN_API_KEY_FILE = config.sops.secrets."pangolin/api_key".path; - # }; - # timeout = 60; - # }; + # --- MCP Pangolin : API d'intégration self-hosted --- + # L'API v1 (clé Bearer, clé "hermes" de la table apiKeys du dashboard) est + # activée par le flag enable_integration_api de services/pangolin et + # répond sur api.marcpartensky.com/v1 (routeur traefik dédié) ; PAS sur + # pangolin.vps.marcpartensky.com qui ne sert que le dashboard. Le script + # (services/hermes/pangolin-mcp.py) relit la clé à chaque appel ; un + # process déjà lancé garde son ancienne PANGOLIN_BASE_URL jusqu'au + # redémarrage de hermes. + services.hermes-agent.mcpServers.pangolin = { + command = "${pangolinMcp}/bin/pangolin-mcp"; + args = [ ]; + env = { + PANGOLIN_BASE_URL = "https://api.marcpartensky.com/v1"; + PANGOLIN_API_KEY_FILE = config.sops.secrets."pangolin_api_key".path; + }; + timeout = 60; + }; # --- MCP Vaultwarden : temporairement désactivé (blocage sops manifest) --- # services.hermes-agent.mcpServers.vaultwarden = { @@ -548,11 +557,18 @@ in { sopsFile = ../../secrets/discord-hermes-bridge.yml; }; - # Clé API Pangolin pour le MCP server (clé "pangolin/api_key" du fichier sops) - # À ajouter (chiffrée) dans secrets/common.yml, clé imbriquée pangolin.api_key. - # Commenté avec le bloc mcpServers.pangolin ci-dessus (29/09/2026) : la clé - # n'existe pas dans secrets/common.yml, ça faisait échouer tout switch. - # sops.secrets."pangolin/api_key" = {}; + # Clé API du MCP Pangolin (clé racine "pangolin_api_key" de + # secrets/common.yml, valeur = clé "hermes" créée dans le dashboard). + # owner hermes + 0400 : le MCP est un process de l'agent et relit ce + # fichier lui-même à chaque appel HTTP. L'ancienne déclaration pointait + # une clé imbriquée pangolin.api_key inexistante ("the key 'pangolin' + # cannot be found" faisait échouer tout switch : laissée hors service + # jusqu'à maintenant). + sops.secrets."pangolin_api_key" = lib.mkIf config.services.hermes.enableAgent { + sopsFile = ../../secrets/common.yml; + owner = "hermes"; + mode = "0400"; + }; # Token Matrix du bot (@hermes) — clé "hermes_matrix_env" du fichier sops, # valeur = contenu dotenv (MATRIX_ACCESS_TOKEN=...) fusionné dans .env. diff --git a/services/hermes/pangolin-mcp.py b/services/hermes/pangolin-mcp.py new file mode 100644 index 0000000..c07a434 --- /dev/null +++ b/services/hermes/pangolin-mcp.py @@ -0,0 +1,647 @@ +"""Serveur MCP Pangolin : Integration API v1 self-hosted. + +Tools curatés (sites, ressources, targets, users, roles, +blueprints...) + un tool auto-généré par endpoint depuis +/openapi.json de l'instance + api_request en échappatoire. +La clé (root API key) est relue à chaque appel depuis le +fichier sops. +""" + +import base64 +import inspect +import json +import os +import re +import urllib.error +import urllib.parse +import urllib.request + +try: + from mcp.server import MCPServer # mcp >= 2 +except ImportError: + from mcp.server.fastmcp import FastMCP as MCPServer # mcp 1.x + +DEFAULT_URL = "https://api.marcpartensky.com/v1" +BASE_URL = os.environ.get("PANGOLIN_BASE_URL", DEFAULT_URL) +KEY_FILE = os.environ.get( + "PANGOLIN_API_KEY_FILE", "/run/secrets/pangolin_api_key" +) +READ_ONLY = os.environ.get("PANGOLIN_READ_ONLY", "0") == "1" +METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH") + +mcp = MCPServer("pangolin") + + +def _api_key(): + key = "" + try: + with open(KEY_FILE) as f: + key = f.read().strip() + except OSError: + pass + key = key or os.environ.get("PANGOLIN_API_KEY", "") + if not key: + raise RuntimeError( + "Clé API absente : " + + KEY_FILE + + " illisible et PANGOLIN_API_KEY vide" + ) + return key + + +def _request(method, path, body=None, query=None, auth=True, + timeout=60): + url = BASE_URL.rstrip("/") + path + if query: + query = {k: v for k, v in query.items() if v is not None} + url += "?" + urllib.parse.urlencode(query) + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + if auth: + headers["Authorization"] = "Bearer " + _api_key() + req = urllib.request.Request( + url, data=data, headers=headers, method=method + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + raw = r.read() + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + raise RuntimeError( + "HTTP %s sur %s %s : %s" + % (e.code, method, path, detail[:600]) + ) + except OSError as e: + raise RuntimeError( + "%s %s injoignable : %s" % (method, path, e) + ) + if not raw: + return {"ok": True} + try: + return json.loads(raw) + except ValueError: + return raw.decode(errors="replace") + + +def _call(method, path, body=None, query=None, timeout=60): + resp = _request( + method, path, body=body, query=query, timeout=timeout + ) + if isinstance(resp, dict) and resp.get("success") is False: + detail = resp.get("message") or str(resp)[:300] + raise RuntimeError( + "API %s %s : %s" % (method, path, detail) + ) + if isinstance(resp, dict): + data = resp.get("data") + return data if data is not None else {"ok": True} + return resp + + +def _write(): + if READ_ONLY: + raise RuntimeError( + "PANGOLIN_READ_ONLY=1 : écriture refusée" + ) + + +@mcp.tool() +def status() -> dict: + """Teste la connexion à l'API Pangolin (clé + URL de base). + + Renvoie base_url, read_only et le nombre d'organisations + visibles ; en cas d'échec l'erreur HTTP brute remonte ici. + """ + out = {"base_url": BASE_URL, "read_only": READ_ONLY} + try: + resp = _request("GET", "/orgs") + except RuntimeError as e: + out["ok"] = False + out["error"] = str(e) + return out + data = resp.get("data") if isinstance(resp, dict) else {} + out["ok"] = True + out["orgs"] = len((data or {}).get("orgs") or []) + return out + + +@mcp.tool() +def list_orgs() -> dict: + """Liste les organisations (orgId, nom, compteurs d'usage).""" + return _call("GET", "/orgs") + + +@mcp.tool() +def get_org(org_id: str) -> dict: + """Détail d'une organisation par son orgId.""" + return _call("GET", "/org/" + org_id) + + +@mcp.tool() +def list_sites(org_id: str) -> dict: + """Liste les sites de l'organisation (siteId, niceId, type).""" + return _call("GET", "/org/" + org_id + "/sites") + + +@mcp.tool() +def get_site(org_id: str, nice_id: str) -> dict: + """Détail d'un site par orgId + niceId (id lisible).""" + return _call( + "GET", "/org/" + org_id + "/site/" + nice_id + ) + + +@mcp.tool() +def create_site( + org_id: str, name: str, subnet: str = "", exit_node_id: int = 0 +) -> dict: + """Crée un site de type newt (tunnel) dans l'organisation. + + Renvoie newtId + secret à réutiliser dans la config newt de + la machine. subnet/exit_node_id : défauts via + pick_site_defaults. Effet de bord (delete_site pour annuler). + """ + _write() + body = {"name": name, "type": "newt"} + if subnet: + body["subnet"] = subnet + if exit_node_id: + body["exitNodeId"] = exit_node_id + return _call("PUT", "/org/" + org_id + "/site", body) + + +@mcp.tool() +def pick_site_defaults(org_id: str) -> dict: + """Défauts pour créer un site (exit node, subnet, newtId...). + + À lire avant create_site pour reprendre subnet/exitNodeId. + """ + return _call("GET", "/org/" + org_id + "/pick-site-defaults") + + +@mcp.tool() +def approve_site(site_id: int) -> dict: + """Approuve un site en attente (et active ses ressources).""" + _write() + return _call( + "POST", "/site/%d/approve" % site_id, {} + ) + + +@mcp.tool() +def delete_site(site_id: int) -> dict: + """Supprime un site et toutes ses données associées. + + Irréversible. + """ + _write() + return _call("DELETE", "/site/%d" % site_id) + + +@mcp.tool() +def list_domains(org_id: str) -> dict: + """Liste les domaines de l'organisation (domainId, base).""" + return _call("GET", "/org/" + org_id + "/domains") + + +@mcp.tool() +def list_resources(org_id: str) -> dict: + """Liste les ressources publiques de l'organisation.""" + return _call( + "GET", "/org/" + org_id + "/public-resources" + ) + + +@mcp.tool() +def get_resource(resource_id: int) -> dict: + """Détail d'une ressource publique (avec targets et policy).""" + return _call( + "GET", "/public-resource/%d" % resource_id + ) + + +@mcp.tool() +def create_resource( + org_id: str, + name: str, + mode: str = "http", + subdomain: str = "", + domain_id: str = "", +) -> dict: + """Crée une ressource publique dans l'organisation. + + mode : http (défaut), ssh, rdp, vnc, tcp, udp, inference. + subdomain/domain_id optionnels (auto sinon). Ajouter ensuite + la cible avec add_target. Effet de bord. + """ + _write() + body = {"name": name, "mode": mode} + if subdomain: + body["subdomain"] = subdomain + if domain_id: + body["domainId"] = domain_id + return _call( + "PUT", "/org/" + org_id + "/public-resource", body + ) + + +@mcp.tool() +def update_resource(resource_id: int, fields: dict) -> dict: + """Met à jour une ressource publique (POST partiel). + + fields : dict des champs modifiés, ex. {"name": "x"} ou + {"subdomain": "y", "domainId": "1"}. Effet de bord. + """ + _write() + return _call( + "POST", "/public-resource/%d" % resource_id, fields + ) + + +@mcp.tool() +def delete_resource(resource_id: int) -> dict: + """Supprime une ressource publique et ses targets. + + Irréversible. + """ + _write() + return _call( + "DELETE", "/public-resource/%d" % resource_id + ) + + +@mcp.tool() +def list_targets(resource_id: int) -> dict: + """Liste les cibles (targets) d'une ressource publique.""" + return _call( + "GET", "/public-resource/%d/targets" % resource_id + ) + + +@mcp.tool() +def add_target( + resource_id: int, + site_id: int, + ip: str, + port: int, + method: str = "http", +) -> dict: + """Ajoute une cible (target) à une ressource publique. + + site_id : site qui joint la cible. ip/port : destination vue + depuis ce site (ex. "127.0.0.1", 8070). method : http, ssh, + rdp, vnc. Effet de bord. + """ + _write() + body = { + "siteId": site_id, + "ip": ip, + "port": port, + "method": method, + } + return _call( + "PUT", "/public-resource/%d/target" % resource_id, body + ) + + +@mcp.tool() +def update_target(target_id: int, fields: dict) -> dict: + """Met à jour une cible (target) par targetId. + + fields : dict des champs modifiés (ip, port, enabled...). + Effet de bord. + """ + _write() + return _call( + "POST", "/target/%d" % target_id, fields + ) + + +@mcp.tool() +def delete_target(target_id: int) -> dict: + """Supprime une cible (target). Irréversible.""" + _write() + return _call("DELETE", "/target/%d" % target_id) + + +@mcp.tool() +def list_private_resources(org_id: str) -> dict: + """Liste les ressources privées (site resources) de l'org.""" + return _call( + "GET", "/org/" + org_id + "/private-resources" + ) + + +@mcp.tool() +def create_private_resource( + org_id: str, + name: str, + mode: str, + site_id: int, + destination: str, + destination_port: int = 0, + alias: str = "", + domain_id: str = "", + subdomain: str = "", + scheme: str = "", + tcp_range: str = "", + udp_range: str = "", + user_ids: list | None = None, + role_ids: list | None = None, + client_ids: list | None = None, +) -> dict: + """Crée une ressource privée (site resource) dans l'org. + + mode : host, cidr, http ou ssh. destination/destination_port : + hôte et port joints par le site ; alias : FQDN interne + optionnel ; domain_id/scheme : requis pour le mode http + (ex. domain1 + http) ; subdomain : sous-domaine à réserver + dans domain_id (ex. cloud → cloud.marcpartensky.com), exclusif + avec alias ; tcp_range/udp_range : plages "1000-2000" ; + user_ids/role_ids/client_ids : accès (vides par défaut). + Effet de bord. + """ + _write() + body = { + "name": name, + "mode": mode, + "siteId": site_id, + "userIds": user_ids or [], + "roleIds": role_ids or [], + "clientIds": client_ids or [], + } + if destination: + body["destination"] = destination + if destination_port: + body["destinationPort"] = destination_port + if alias: + body["alias"] = alias + if domain_id: + body["domainId"] = domain_id + if subdomain: + body["subdomain"] = subdomain + if scheme: + body["scheme"] = scheme + if tcp_range: + body["tcpPortRangeString"] = tcp_range + if udp_range: + body["udpPortRangeString"] = udp_range + return _call( + "PUT", "/org/" + org_id + "/private-resource", body + ) + + +@mcp.tool() +def get_private_resource(org_id: str, site_resource_id: int) -> dict: + """Détail d'une ressource privée par siteResourceId.""" + return _call( + "GET", + "/private-resource/%d" % site_resource_id, + query={"orgId": org_id}, + ) + + +@mcp.tool() +def delete_private_resource(site_resource_id: int) -> dict: + """Supprime une ressource privée. Irréversible.""" + _write() + return _call( + "DELETE", "/private-resource/%d" % site_resource_id + ) + + +@mcp.tool() +def update_private_resource(site_resource_id: int, fields: dict) -> dict: + """Met à jour une ressource privée (POST partiel). + + fields : champs à modifier (ex. {"subdomain": "cloud.p2p"} ou + {"enabled": false}) ; les champs omis restent inchangés. Pour + les modes http, domainId + subdomain vont ensemble. Effet de bord. + """ + _write() + return _call( + "POST", "/private-resource/%d" % site_resource_id, fields + ) + + +@mcp.tool() +def list_roles(org_id: str) -> dict: + """Liste les rôles de l'organisation (roleId, nom).""" + return _call("GET", "/org/" + org_id + "/roles") + + +@mcp.tool() +def list_users(org_id: str) -> dict: + """Liste les utilisateurs de l'organisation.""" + return _call("GET", "/org/" + org_id + "/users") + + +@mcp.tool() +def create_user( + org_id: str, + username: str, + email: str = "", + name: str = "", + role_ids: list | None = None, +) -> dict: + """Crée un utilisateur interne dans l'organisation. + + Sans mot de passe ni invitation envoyée, il ne peut pas se + connecter. Effet de bord, vérifier username/email avant. + """ + _write() + body = {"username": username} + if email: + body["email"] = email + if name: + body["name"] = name + if role_ids: + body["roleIds"] = role_ids + return _call("PUT", "/org/" + org_id + "/user", body) + + +@mcp.tool() +def list_blueprints(org_id: str) -> dict: + """Liste les blueprints appliqués à l'organisation.""" + return _call("GET", "/org/" + org_id + "/blueprints") + + +@mcp.tool() +def apply_blueprint(org_id: str, blueprint_json: str) -> dict: + """Applique un blueprint JSON à l'organisation. + + blueprint_json : JSON brut en texte (pas du YAML ni du + base64, l'encodage base64 est fait ici). Peut créer/modifier + sites, ressources et cibles. Effet de bord important. + """ + _write() + b64 = base64.b64encode( + blueprint_json.encode() + ).decode() + return _call( + "PUT", "/org/" + org_id + "/blueprint", + {"blueprint": b64}, + ) + + +@mcp.tool() +def list_api_keys(org_id: str) -> dict: + """Liste les clés API de l'org (jamais la valeur, l'id seul).""" + return _call("GET", "/org/" + org_id + "/api-keys") + + +@mcp.tool() +def api_request( + method: str, + path: str, + body: dict | None = None, + query: dict | None = None, +) -> dict | list | str: + """Appelle un endpoint v1 arbitraire (couverture complète). + + method : GET, POST, PUT, PATCH ou DELETE. path : chemin + après /v1, ex. "/org/{orgId}/sites". body/query : dicts + JSON optionnels. Voir list_api_endpoints pour trouver la + route adéquate. + """ + m = method.upper() + if m not in METHODS: + raise RuntimeError("méthode non supportée : " + method) + if not path.startswith("/"): + path = "/" + path + if m != "GET": + _write() + return _call(m, path, body, query) + + +@mcp.tool() +def list_api_endpoints(search: str = "") -> list: + """Liste les endpoints de la spec de l'instance (filtré). + + Renvoie "METHOD /chemin | résumé" des opérations contenant + `search` (défaut : tout, plafonné à 300). Sert à préparer un + api_request. + """ + spec = _request( + "GET", "/openapi.json", auth=False, timeout=20 + ) + out = [] + for p, ops in (spec.get("paths") or {}).items(): + for m, op in ops.items(): + if m.upper() not in METHODS: + continue + line = "%s %s | %s" % ( + m.upper(), p, (op.get("summary") or "") + ) + if not search or search.lower() in line.lower(): + out.append(line) + return out[:300] + + +def _path_params(path_tpl): + return [n for n in re.findall(r"\{([^{}]+)\}", path_tpl) if n] + + +def _endpoint_fn(method, path_tpl, param_names): + def fn(**kwargs): + args = {k: kwargs.pop(k) for k in param_names} + body = kwargs.pop("body", None) + query = kwargs.pop("query", None) + if method != "GET": + _write() + path = path_tpl + for k, v in args.items(): + path = path.replace( + "{" + k + "}", + urllib.parse.quote(str(v), safe=""), + ) + return _call(method, path, body, query) + + sig = [ + inspect.Parameter( + k, + inspect.Parameter.KEYWORD_ONLY, + annotation=str | int, + ) + for k in param_names + ] + sig.append( + inspect.Parameter( + "body", + inspect.Parameter.KEYWORD_ONLY, + annotation=dict | None, + default=None, + ) + ) + sig.append( + inspect.Parameter( + "query", + inspect.Parameter.KEYWORD_ONLY, + annotation=dict | None, + default=None, + ) + ) + fn.__signature__ = inspect.Signature(sig) + return fn + + +def _register(fn, name, doc): + if hasattr(mcp, "add_tool"): + mcp.add_tool(fn, name=name, description=doc) + else: # pragma: no cover - vieux FastMCP + fn.__name__ = name + fn.__doc__ = doc + mcp.tool()(fn) + + +def _register_endpoint_tools(): + try: + spec = _request( + "GET", "/openapi.json", auth=False, timeout=10 + ) + except RuntimeError: + return 0 + if not isinstance(spec, dict): + return 0 + seen = set() + count = 0 + for path_tpl, ops in (spec.get("paths") or {}).items(): + if path_tpl in ("/", "/openapi.json", "/openapi.yaml"): + continue + for method, op in ops.items(): + m = method.upper() + if m not in METHODS: + continue + raw = op.get("operationId") or (m + " " + path_tpl) + name = re.sub(r"[^0-9a-zA-Z_]+", "_", raw).strip("_") + name = name or "endpoint" + base, i = name, 2 + while name in seen: + name = "%s_%d" % (base, i) + i += 1 + seen.add(name) + bits = [m + " " + path_tpl] + summ = (op.get("summary") or "").strip() + if summ: + bits.append(summ) + tags = ", ".join(op.get("tags") or []) + if tags: + bits.append("tags: " + tags) + bits.append( + "Auto-généré depuis /openapi.json (spec de " + "l'instance) ; body/query = dicts JSON optionnels." + ) + fn = _endpoint_fn( + m, path_tpl, _path_params(path_tpl) + ) + _register(fn, name, "\n".join(bits)) + count += 1 + return count + + +if __name__ == "__main__": + _register_endpoint_tools() + mcp.run() diff --git a/services/pangolin/default.nix b/services/pangolin/default.nix index 0a41808..7a90e43 100644 --- a/services/pangolin/default.nix +++ b/services/pangolin/default.nix @@ -20,7 +20,21 @@ services.pangolin = { enable = true; - # package = pkgs.fosrl-pangolin; + # Patch « stub de licence » (décision marc, 2026-10-23) : les builds + # open-source compilent server/lib/isLicencedOrSubscribed.ts, un stub figé + # à `return false` (l'édition oss de nixpkgs supprime même server/private, + # qui contient la vraie logique réservée aux builds saas/enterprise). + # Conséquence : toutes les features « gated » — dont les ressources privées + # http/ssh, nécessaires au P2P nextcloud depuis NYC — renvoient 403 + # "Please upgrade". On remplace le stub par `return true`. + # À assumer : contourne le gate commercial de Fossorial sur notre instance. + # Alternative propre si un jour : edition = "enterprise" + une licenseKey. + package = pkgs.fosrl-pangolin.overrideAttrs (old: { + postPatch = (old.postPatch or "") + '' + substituteInPlace server/lib/isLicencedOrSubscribed.ts \ + --replace-fail 'return false;' 'return true;' + ''; + }); baseDomain = "marcpartensky.com"; dashboardDomain = "pangolin.vps.marcpartensky.com"; letsEncryptEmail = "marc.partensky@proton.me"; @@ -41,9 +55,22 @@ internal_port = 3008; internal_hostname = "127.0.0.1"; }; + # API d'intégration v1 (auth par clé Bearer, table apiKeys du + # dashboard) : utilisée par le MCP pangolin de hermes. Le module ajoute + # alors le routeur traefik + le service vers localhost:3003, exposés + # sur api.marcpartensky.com (Host exact, cf. int-api-router du module). + # Défaut : désactivée. + flags = { + enable_integration_api = true; + }; postgres = { - connection_string = - "postgresql://pangolin:MarcPangolin44@127.0.0.1:5432/pangolin"; + # Pas de mot de passe ici : la vraie connection string arrive par + # l'environnement du service (variable POSTGRES_CONNECTION_STRING du + # environmentFile sops ci-dessus, clé "pangolin_env" de + # secrets/common.yml) et écrase cette valeur au démarrage. Ce fichier + # part dans le store et le repo (public), donc aucun secret ne doit y + # figurer, même masqué. + connection_string = "postgresql://pangolin@127.0.0.1:5432/pangolin"; }; }; }; diff --git a/services/traefik/dynamic.toml b/services/traefik/dynamic.toml index 9e79036..51b08f0 100644 --- a/services/traefik/dynamic.toml +++ b/services/traefik/dynamic.toml @@ -23,6 +23,15 @@ [[http.services.zitadel.loadBalancer.servers]] url = "http://127.0.0.1:2080" +# API d'intégration Pangolin (MCP, scripts) — activée par +# flags.enable_integration_api dans services/pangolin/default.nix. +# Le module nixpkgs pangolin définit ces routeurs via dynamicConfigOptions, +# mais cette archi-ci (dynamicConfigFile custom) ne les prend pas : la route +# est donc posée ici, main dans la main avec le flag. +[http.services.int-api.loadBalancer] + [[http.services.int-api.loadBalancer.servers]] + url = "http://127.0.0.1:3003" + # --- ROUTERS --- [http.routers.traefik] rule = "Host(`traefik.vps.marcpartensky.com`)" @@ -67,3 +76,9 @@ entryPoints = ["websecure"] service = "zitadel" tls = { certResolver = "letsencrypt" } + +[http.routers.int-api] + rule = "Host(`api.marcpartensky.com`)" + entryPoints = ["websecure"] + service = "int-api" + tls = { certResolver = "letsencrypt" }