portail 2.1.0

Unified proxy/gateway: AI Gateway + MCP Gateway + CDN cache
Documentation
{ config, lib, pkgs, ... }:

let
  cfg = config.services.portail;
  inherit (lib) mkIf mkEnableOption mkOption types;
in
{
  options.services.portail = {
    enable = mkEnableOption "Portail — unified proxy/gateway";

    package = mkOption {
      type = types.package;
      description = "portail package to use";
    };

    mcpPlugin = mkOption {
      type = types.package;
      description = "portail-mcp Python plugin package";
    };

    listen = mkOption {
      type = types.str;
      default = "0.0.0.0:8787";
      description = "Portail listen address";
    };

    cacheDir = mkOption {
      type = types.str;
      default = "/var/cache/portail";
      description = "CDN cache directory";
    };

    cacheSize = mkOption {
      type = types.str;
      default = "10g";
      description = "CDN cache max size";
    };

    enableAiGateway = mkOption {
      type = types.bool;
      default = true;
      description = "Enable AI Gateway subsystem (proxies to LiteLLM upstream)";
    };

    enableMcp = mkOption {
      type = types.bool;
      default = true;
      description = "Enable MCP Gateway subsystem (proxies to Python sidecar)";
    };

    enableCdn = mkOption {
      type = types.bool;
      default = false;
      description = "Enable CDN cache subsystem";
    };

    aiUpstream = mkOption {
      type = types.str;
      default = "http://127.0.0.1:4000";
      description = "AI gateway upstream URL (LiteLLM or compatible)";
    };

    cdnOrigin = mkOption {
      type = types.str;
      default = "http://127.0.0.1:9000";
      description = "CDN origin (MinIO or S3-compatible)";
    };

    natsUrl = mkOption {
      type = types.nullOr types.str;
      default = null;
      description = "NATS URL for CDN cache invalidation";
    };

    mcpConfig = mkOption {
      type = types.nullOr types.path;
      default = null;
      description = "Path to MCP server config JSON";
    };

    cdnDomains = mkOption {
      type = types.listOf types.str;
      default = [];
      description = "CDN domain names (e.g., cdn.example.com)";
    };

    environment = mkOption {
      type = types.attrsOf types.str;
      default = {};
      description = "Extra environment variables";
    };

    openFirewall = mkOption {
      type = types.bool;
      default = false;
      description = "Open the portail port in the firewall";
    };

    memoryMax = mkOption {
      type = types.nullOr types.str;
      default = null;
      description = "Systemd MemoryMax limit (e.g. 512M)";
    };

    tasksMax = mkOption {
      type = types.nullOr types.str;
      default = null;
      description = "Systemd TasksMax limit";
    };
  };

  config = mkIf cfg.enable {
    users.users.portail = {
      isSystemUser = true;
      group = "portail";
      home = "/var/lib/portail";
      createHome = true;
      description = "Portail daemon user";
    };
    users.groups.portail = {};

    systemd.tmpfiles.rules = [
      "d ${cfg.cacheDir} 0750 portail portail - -"
      "d /var/lib/portail 0750 portail portail - -"
      "d /run/portail 0755 portail portail - -"
    ];

    # ── Portail main service (Rust binary) ──────────────────
    systemd.services.portail = {
      description = "Portail — unified proxy/gateway";
      after = [ "network-online.target" ];
      wants = [ "network-online.target" ];
      wantedBy = [ "multi-user.target" ];

      serviceConfig = {
        User = "portail";
        Group = "portail";
        ExecStart = "${cfg.package}/bin/portail";
        Restart = "on-failure";
        RestartSec = "5s";
        StateDirectory = "portail";
        CacheDirectory = "portail";
        RuntimeDirectory = "portail";
        RuntimeDirectoryPreserve = "yes";

        # ── Hardening ──────────────────────────────────────
        NoNewPrivileges = true;
        ProtectSystem = "strict";
        ProtectHome = true;
        PrivateTmp = true;
        ProtectClock = true;
        ProtectKernelTunables = true;
        ProtectKernelModules = true;
        ProtectKernelLogs = true;
        ProtectControlGroups = true;
        PrivateDevices = true;
        LockPersonality = true;
        RestrictRealtime = true;
        RestrictSUIDSGID = true;
        CapabilityBoundingSet = [ "" ];
        SystemCallArchitectures = "native";
        SystemCallFilter = [ "@system-service" "~@privileged" ];
        IPAddressDeny = "any";

        # ── Resource limits ────────────────────────────────
        OOMScoreAdjust = -500;
      } // lib.optionalAttrs (cfg.memoryMax != null) {
        MemoryMax = cfg.memoryMax;
      } // lib.optionalAttrs (cfg.tasksMax != null) {
        TasksMax = cfg.tasksMax;
      };

      environment = {
        PORTAIL_SILENCE_SIGTERM = "1";
      } // lib.optionalAttrs (cfg.enableAiGateway) {
        PORTAIL_ENABLE_AI_GATEWAY = "true";
        PORTAIL_AI_UPSTREAM = cfg.aiUpstream;
        PORTAIL_LISTEN = cfg.listen;
      } // lib.optionalAttrs (cfg.enableMcp) {
        PORTAIL_ENABLE_MCP = "true";
        PORTAIL_MCP_SOCKET = "/run/portail/mcp.sock";
      } // lib.optionalAttrs (cfg.enableCdn) {
        PORTAIL_ENABLE_CDN = "true";
        PORTAIL_CACHE_DIR = cfg.cacheDir;
        PORTAIL_CACHE_SIZE = cfg.cacheSize;
        PORTAIL_CDN_ORIGIN = cfg.cdnOrigin;
      } // lib.optionalAttrs (cfg.natsUrl != null) {
        PORTAIL_NATS_URL = cfg.natsUrl;
      } // cfg.environment;

      preStop = ''
        /run/current-system/sw/bin/kill -TERM $MAINPID
      '';
    };

    # ── MCP sidecar service (Python) ────────────────────────
    systemd.services.portail-mcp = mkIf cfg.enableMcp {
      description = "Portail MCP Gateway — Python sidecar";
      after = [ "network-online.target" ];
      bindsTo = [ "portail.service" ];
      wantedBy = [ "multi-user.target" ];

      serviceConfig = {
        User = "portail";
        Group = "portail";
        ExecStart = "${cfg.mcpPlugin}/bin/portail-mcp --socket /run/portail/mcp.sock${lib.optionalString (cfg.mcpConfig != null) " --config ${cfg.mcpConfig}"}";
        Restart = "on-failure";
        RestartSec = "5s";
        RuntimeDirectory = "portail";
        RuntimeDirectoryPreserve = "yes";

        # ── Hardening ──────────────────────────────────────
        NoNewPrivileges = true;
        ProtectSystem = "strict";
        ProtectHome = true;
        PrivateTmp = true;
        ProtectClock = true;
        ProtectKernelTunables = true;
        ProtectKernelModules = true;
        ProtectKernelLogs = true;
        ProtectControlGroups = true;
        PrivateDevices = true;
        LockPersonality = true;
        RestrictRealtime = true;
        RestrictSUIDSGID = true;
        CapabilityBoundingSet = [ "" ];
        SystemCallArchitectures = "native";
        SystemCallFilter = [ "@system-service" "~@privileged" ];
        IPAddressDeny = "any";
        OOMScoreAdjust = -500;
      };

      environment = {
        PORTAIL_SILENCE_SIGTERM = "1";
        PORTAIL_MCP_SOCKET = "/run/portail/mcp.sock";
      } // cfg.environment;
    };

    # ── Firewall ────────────────────────────────────────────
    networking.firewall = mkIf cfg.openFirewall {
      allowedTCPPorts = [
        (lib.toInt (builtins.elemAt (lib.splitString ":" cfg.listen) 1))
      ];
    };
  };
}