Harden server; add authenticated Streamable HTTP transport and Nix module
- Streamable HTTP transport (MCP_TRANSPORT=http) with mandatory auth: static bearer tokens or OIDC resource-server mode (JWT via JWKS, or RFC 7662 introspection), RFC 9728 protected-resource metadata, and DNS-rebinding protection - secrets loadable from files via *_FILE variants - timeouts on all outbound requests; JORTT_READ_ONLY tool filtering - OpenAPI spec committed and bundled; no implicit network fetches - Nix flake: sandboxed package build + hardened NixOS module taking an EnvironmentFile (agenix-friendly) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
240a53fade
commit
a73af353d4
17 changed files with 1255 additions and 77 deletions
258
nix/module.nix
Normal file
258
nix/module.nix
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.jortt-mcp;
|
||||
in
|
||||
{
|
||||
options.services.jortt-mcp = {
|
||||
enable = lib.mkEnableOption "jortt-mcp, an MCP server for the Jortt accounting API";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "The jortt-mcp package to run.";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = ''
|
||||
Address to bind the Streamable HTTP listener to. Keep this on
|
||||
loopback and terminate TLS in a reverse proxy (nginx, caddy, ...);
|
||||
the server itself speaks plain HTTP.
|
||||
'';
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 3910;
|
||||
description = "Port for the Streamable HTTP listener.";
|
||||
};
|
||||
|
||||
environmentFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
example = "/run/agenix/jortt-mcp.env";
|
||||
description = ''
|
||||
systemd EnvironmentFile with the secrets, in KEY=value format.
|
||||
Intended for an agenix-managed file, e.g.
|
||||
{command}`age.secrets.jortt-mcp-env.file`.
|
||||
|
||||
Required keys: {env}`JORTT_CLIENT_ID`, {env}`JORTT_CLIENT_SECRET`.
|
||||
For `auth.mode = "token"` also set {env}`MCP_AUTH_TOKEN` (one or
|
||||
more tokens, comma- or newline-separated). For OIDC token
|
||||
introspection also set {env}`MCP_OIDC_CLIENT_SECRET`.
|
||||
'';
|
||||
};
|
||||
|
||||
readOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Expose only GET operations (list/get tools). A useful hard stop on
|
||||
top of OAuth scopes when the assistant should not create or modify
|
||||
anything in the administration.
|
||||
'';
|
||||
};
|
||||
|
||||
scopes = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.listOf lib.types.str);
|
||||
default = null;
|
||||
example = [ "invoices:read" "customers:read" "reports:read" ];
|
||||
description = ''
|
||||
Jortt OAuth scopes to request. Defaults to all scopes the
|
||||
registered Jortt application was granted.
|
||||
'';
|
||||
};
|
||||
|
||||
auth = {
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "token" "oidc" ];
|
||||
description = ''
|
||||
How incoming MCP requests are authenticated.
|
||||
|
||||
`token`: static bearer token(s), supplied via
|
||||
{env}`MCP_AUTH_TOKEN` in {option}`environmentFile`. Works with
|
||||
clients that can send an Authorization header (Claude Code,
|
||||
the Claude API).
|
||||
|
||||
`oidc`: validate OAuth 2.0 access tokens issued by an external
|
||||
authorization server or OAuth proxy (Keycloak, Authelia,
|
||||
Pomerium, ...). The server acts as an OAuth resource server per
|
||||
the MCP authorization spec and publishes RFC 9728
|
||||
protected-resource metadata for client discovery. Required for
|
||||
claude.ai remote connectors.
|
||||
'';
|
||||
};
|
||||
|
||||
oidc = {
|
||||
issuer = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "https://auth.example.com";
|
||||
description = "Issuer URL of the authorization server / OAuth proxy.";
|
||||
};
|
||||
|
||||
audience = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Audience the access token must carry. Defaults to
|
||||
{option}`resourceUrl`.
|
||||
'';
|
||||
};
|
||||
|
||||
allowedSubjects = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "kate" "kate@example.com" ];
|
||||
description = ''
|
||||
When non-empty, only tokens whose subject, preferred_username
|
||||
or email matches one of these values are accepted. Strongly
|
||||
recommended: any account on the IdP can otherwise obtain a
|
||||
valid token for this (single-administration) server.
|
||||
'';
|
||||
};
|
||||
|
||||
introspectionUrl = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
RFC 7662 token introspection endpoint, for authorization
|
||||
servers that issue opaque (non-JWT) access tokens, e.g.
|
||||
Authelia. When set (or when {option}`introspectionClientId`
|
||||
is set, using endpoint discovery), tokens are introspected
|
||||
instead of validated as JWTs; the introspection client
|
||||
secret comes from {env}`MCP_OIDC_CLIENT_SECRET` in
|
||||
{option}`environmentFile`.
|
||||
'';
|
||||
};
|
||||
|
||||
introspectionClientId = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Client ID used to authenticate to the introspection endpoint.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
resourceUrl = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "https://jortt-mcp.example.com/mcp";
|
||||
description = ''
|
||||
Public URL of the MCP endpoint as reached through the reverse
|
||||
proxy. Used as the OAuth resource identifier / token audience and
|
||||
in the published protected-resource metadata.
|
||||
'';
|
||||
};
|
||||
|
||||
allowedHosts = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "jortt-mcp.example.com" ];
|
||||
description = ''
|
||||
Exact values the HTTP Host header must match (DNS-rebinding
|
||||
protection). Include the public hostname (add `:port` if
|
||||
non-standard). Leave empty to disable the check, e.g. when a
|
||||
reverse proxy already enforces the virtual host.
|
||||
'';
|
||||
};
|
||||
|
||||
allowedOrigins = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Exact Origin header values to accept (browser-based clients).";
|
||||
};
|
||||
|
||||
extraEnvironment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
description = "Extra environment variables for the service (non-secret).";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Open the firewall for the listener port (only sensible with a non-loopback host).";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.environmentFile != null;
|
||||
message = "services.jortt-mcp.environmentFile is required: it must provide JORTT_CLIENT_ID and JORTT_CLIENT_SECRET (and MCP_AUTH_TOKEN in token mode).";
|
||||
}
|
||||
{
|
||||
assertion = cfg.auth.mode != "oidc" || cfg.auth.oidc.issuer != null;
|
||||
message = "services.jortt-mcp.auth.oidc.issuer must be set when auth.mode = \"oidc\".";
|
||||
}
|
||||
{
|
||||
assertion = cfg.auth.mode != "oidc" || cfg.resourceUrl != null || cfg.auth.oidc.audience != null;
|
||||
message = "services.jortt-mcp: with auth.mode = \"oidc\", set resourceUrl (recommended) or auth.oidc.audience.";
|
||||
}
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
|
||||
systemd.services.jortt-mcp = {
|
||||
description = "Jortt MCP server";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
environment = {
|
||||
MCP_TRANSPORT = "http";
|
||||
MCP_HOST = cfg.host;
|
||||
MCP_PORT = toString cfg.port;
|
||||
MCP_AUTH_MODE = cfg.auth.mode;
|
||||
}
|
||||
// lib.optionalAttrs cfg.readOnly { JORTT_READ_ONLY = "1"; }
|
||||
// lib.optionalAttrs (cfg.scopes != null) { JORTT_SCOPES = lib.concatStringsSep " " cfg.scopes; }
|
||||
// lib.optionalAttrs (cfg.resourceUrl != null) { MCP_RESOURCE_URL = cfg.resourceUrl; }
|
||||
// lib.optionalAttrs (cfg.allowedHosts != [ ]) { MCP_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts; }
|
||||
// lib.optionalAttrs (cfg.allowedOrigins != [ ]) { MCP_ALLOWED_ORIGINS = lib.concatStringsSep "," cfg.allowedOrigins; }
|
||||
// lib.optionalAttrs (cfg.auth.oidc.issuer != null) { MCP_OIDC_ISSUER = cfg.auth.oidc.issuer; }
|
||||
// lib.optionalAttrs (cfg.auth.oidc.audience != null) { MCP_OIDC_AUDIENCE = cfg.auth.oidc.audience; }
|
||||
// lib.optionalAttrs (cfg.auth.oidc.allowedSubjects != [ ]) {
|
||||
MCP_OIDC_ALLOWED_SUBJECTS = lib.concatStringsSep "," cfg.auth.oidc.allowedSubjects;
|
||||
}
|
||||
// lib.optionalAttrs (cfg.auth.oidc.introspectionUrl != null) { MCP_OIDC_INTROSPECTION_URL = cfg.auth.oidc.introspectionUrl; }
|
||||
// lib.optionalAttrs (cfg.auth.oidc.introspectionClientId != null) { MCP_OIDC_CLIENT_ID = cfg.auth.oidc.introspectionClientId; }
|
||||
// cfg.extraEnvironment;
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = lib.getExe cfg.package;
|
||||
EnvironmentFile = [ cfg.environmentFile ];
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# Hardening
|
||||
DynamicUser = true;
|
||||
NoNewPrivileges = true;
|
||||
UMask = "0077";
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
ProtectClock = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectHostname = true;
|
||||
ProtectProc = "invisible";
|
||||
ProcSubset = "pid";
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
LockPersonality = true;
|
||||
SystemCallArchitectures = "native";
|
||||
SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
|
||||
CapabilityBoundingSet = "";
|
||||
AmbientCapabilities = "";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue