100% free and open source · every tool, every host, no licence key · read the licence
Secronyx

Deploy

Remote access over HTTP

Exposing Secronyx over the HTTP transport — the exposure rules the binary enforces at start-up, TLS and mutual TLS, reverse proxies and trusted proxy headers, CORS, rate limiting and the health and metrics endpoints.

Secronyx speaks MCP over stdio by default and opens no socket at all. --transport http turns the same server into a JSON-RPC 2.0 endpoint reachable over the network, so an orchestrator, a script or an AI client on another machine can call the tools. Everything below is taken from internal/mcp/http.go, internal/mcp/ratelimit.go and the transport wiring in cmd/secronyx/main.go. Where older documents in the repository disagree with that code, this page follows the code.

Credential formats and the authenticator chain are covered in Authentication. This page is about how to bind, encrypt and front the listener, and what the server refuses to do.

What the listener serves

Start registers exactly four routes on the mux:

Route Methods Authentication Purpose
/ POST Required when any method is configured The MCP JSON-RPC endpoint
/.well-known/oauth-protected-resource GET None RFC 9728 protected resource metadata
/health GET, HEAD None Liveness only
/metrics GET Same as / (wrapped in requireAuth) Prometheus exposition

Any other method on / returns 405 Method not allowed. /health returns {"status":"ok"} with Cache-Control: no-store and deliberately says nothing about the transport, the auth method or the version — a probe does not need that, and an attacker would.

/metrics is authenticated because the series name every tool called and every authentication failure. The metric namespace is secronyx: secronyx_http_requests_total{method,path,status}, secronyx_http_request_duration_seconds{method,path}, secronyx_tool_calls_total{tool,scope}, secronyx_tool_call_duration_seconds{tool}, secronyx_tool_call_errors_total{tool,error_type}, secronyx_auth_requests_total{result} and secronyx_server_info{version,transport,auth_method}, plus the standard Go runtime and process collectors.

The HTTP server itself is built with ReadHeaderTimeout: 10s, ReadTimeout: 30s, WriteTimeout: 30s, IdleTimeout: 60s and MaxHeaderBytes: 64 << 10 (64 KiB).

The exposure rules

HTTPConfig.Validate runs before a socket is opened. It reads no files and opens no connections; it is a pure check of the configuration you asked for. Three rules apply.

A static bearer token must be at least 32 characters. MinBearerTokenLength is 32, and a shorter token fails with:

bearer token is 12 characters; a static token must be at least 32 characters of random data

An unauthenticated listener must be on loopback. Otherwise:

refusing to listen on "0.0.0.0:8080" without authentication: bind to 127.0.0.1, or configure
--token/--oidc-issuer/--auth-server, or pass --allow-unauthenticated for an isolated development network

An authenticated listener off loopback must have TLS. A bearer token crossing plaintext HTTP is no better than no token:

refusing to listen on "0.0.0.0:8443" with authentication but without TLS: credentials would cross the
network in plaintext; add --tls-cert/--tls-key, bind to 127.0.0.1 behind a TLS-terminating proxy, or pass
--allow-unauthenticated for an isolated development network

There is a fourth, unconditional rule: --tls-client-ca (mutual TLS) fails immediately without a server certificate — a client CA bundle (mutual TLS) requires --tls-cert and --tls-key.

"Loopback" is decided by isLoopbackAddr, and it is deliberately conservative. localhost and any address that parses as a loopback IP qualify. An empty host, 0.0.0.0, ::, and any hostname that is not localhost do not — the code cannot verify what a name resolves to, so it treats it as exposed. --listen :8080 is therefore an exposed listener, not a local one.

--allow-unauthenticated (or SECRONYX_ALLOW_UNAUTHENTICATED=1) downgrades rules two and three from a refusal to a logged warning, and Start prints the warning verbatim:

SECURITY WARNING: serving "0.0.0.0:8080" WITHOUT AUTHENTICATION because --allow-unauthenticated is set;
anyone who can reach this port can read system state

Treat that flag as a development-network switch. It is why the shipped Compose file can bind 0.0.0.0 inside the container with a token but no TLS, publishing only to 127.0.0.1 on the host; in the Helm chart it is passed only when mcp.allowInsecure is set, and that value defaults to false.

Serving TLS from the binary

secronyx --transport http \
  --listen 0.0.0.0:8443 \
  --server-url https://mcp.example.com \
  --tls-cert /etc/mcp/cert.pem \
  --tls-key /etc/mcp/key.pem \
  --token "$(openssl rand -base64 32)"

The TLS configuration is fixed in code: MinVersion: tls.VersionTLS12, with the cipher suite list restricted to the six ECDHE AEAD suites (TLS_ECDHE_{ECDSA,RSA}_WITH_{AES_128_GCM_SHA256,AES_256_GCM_SHA384,CHACHA20_POLY1305}). TLS 1.3 negotiates its own suites and is preferred. There is no flag to weaken any of this.

--server-url is the public URL advertised in the RFC 9728 metadata document and in the resource_metadata parameter of the WWW-Authenticate challenge. When it is not set the binary derives it from the listen address and whether a certificate was supplied (https:// when --tls-cert is present, otherwise http://), which is rarely what you want behind a proxy — set it explicitly.

For mutual TLS, add --tls-client-ca. The listener then uses tls.RequireAndVerifyClientCert, or tls.VerifyClientCertIfGiven when --tls-client-cert-optional is set for a migration. Identity mapping is done by the mTLS authenticator (--mtls-identity-file, --mtls-default-scopes, --mtls-require-mapping, --mtls-trust-domain, --mtls-crl); see Tutorial: mutual TLS end to end.

Terminating TLS in a reverse proxy

The supported alternative to serving TLS from the binary is to bind loopback and let a proxy do the TLS. Rule three is satisfied because the listener is on loopback:

secronyx --transport http \
  --listen 127.0.0.1:8080 \
  --server-url https://mcp.example.com \
  --token "$(openssl rand -base64 32)" \
  --trust-proxy-headers
server {
    listen 443 ssl;
    http2 on;
    server_name mcp.example.com;

    ssl_certificate     /etc/ssl/certs/mcp.crt;
    ssl_certificate_key /etc/ssl/private/mcp.key;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }
}

--trust-proxy-headers is what makes X-Forwarded-For and X-Real-IP believed. Without it, getClientIP uses RemoteAddr only, so every audit record would say 127.0.0.1 and every rate-limit bucket would be shared. With it, the first address in the X-Forwarded-For chain wins, falling back to X-Real-IP, falling back to RemoteAddr.

The flag is a loaded gun on a directly exposed listener: those headers are caller-controlled, so a client could forge the address written to the audit log and hop between rate-limit buckets at will. Set it only when the listener cannot be reached except through the proxy, and make sure the proxy overwrites rather than appends the client-supplied header. main.go copies the same setting into the rate limiter (rl.TrustProxyHeaders = httpConfig.TrustProxyHeaders), so the two can never disagree.

The client IP is attached to the request context before the authenticators run, because API key CIDR pinning (allowed_cidrs) and SSH from= options are evaluated against it. A wrong proxy setting therefore breaks those controls as well as the audit trail.

Browsers and CORS

No CORS headers are sent unless --cors-origin lists an origin. This is the default for a reason: with no headers, a page on any site cannot read a response from a developer's loopback listener.

--cors-origin https://console.example.com,https://ops.example.com reflects an origin back only on an exact, case-insensitive match, and adds Vary: Origin alongside it. The advertised methods are GET, POST, OPTIONS and the advertised request headers are Authorization, Content-Type. Any OPTIONS request is answered with 204 No Content and goes no further, whether or not an origin matched. Access-Control-Allow-Credentials is never set. A literal * entry is honoured only when no authentication is configured at all; with auth on it is skipped, because a wildcard would let any page that has obtained a token relay it from anywhere.

Rate limiting and lockout

Rate limiting is on by default (--rate-limit, disable with --rate-limit=false, which logs WARNING: HTTP rate limiting and brute-force lockout are DISABLED). The middleware wraps the entire handler, outermost, so a rejected request is never logged or counted as a tool call. DefaultRateLimitConfig is:

Setting Default Effect
RequestsPerSecond 20 Sustained per-client rate
Burst 40 Tokens available at once
MaxConcurrent 32 In-flight requests across all clients
LockoutThreshold 10 401 responses before lockout
LockoutDuration 15m Both the counting window and the lockout
MaxClients 10000 Tracked client addresses (LRU-bounded)

Exceeding the bucket or being locked out returns 429 with a Retry-After header; exceeding MaxConcurrent returns 503 server busy with Retry-After: 1.

Authentication failures

A rejected request gets 401 with a challenge header and a JSON body:

WWW-Authenticate: Bearer realm="secronyx", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
{"error": "unauthorized", "error_description": "invalid token"}

Both the success and the failure are written to the audit log with the action auth/token_validation, the client IP, and (on success) the subject as the identity plus the client id, the granted scopes and the name of the authenticator that accepted the credential in the metadata. A failure records the rejection reason instead. See Audit logging.

Running it as a daemon

The binary installs its own systemd unit:

sudo secronyx service install -- \
  --transport http --listen 127.0.0.1:8080 --token "$SECRONYX_TOKEN"
sudo secronyx service start

The generated unit at /etc/systemd/system/secronyx.service runs ExecStart=<abs path> service run <args>, sets Restart=on-failure with RestartSec=5, StartLimitBurst=5, StartLimitIntervalSec=60, logs to the journal under SyslogIdentifier=<name>, and applies NoNewPrivileges=true, ProtectSystem=strict, ProtectHome=read-only, PrivateTmp=true, ProtectKernelTunables=true, ProtectKernelModules=true and ProtectControlGroups=true. Install, uninstall, start and stop all require root; --name accepts ^[a-zA-Z][a-zA-Z0-9_-]{0,62}$ only.

Note that ProtectSystem=strict makes the filesystem read-only apart from explicitly allowed paths, so either set --audit-output to a writable location you have permitted or accept the documented fallback to stderr (and therefore the journal). The default audit path is /var/log/secronyx/audit.jsonl.

Checklist

  • Bind 127.0.0.1 unless you have a certificate; let a proxy or the platform terminate TLS.
  • Generate tokens with openssl rand -base64 32; the binary rejects anything under 32 characters.
  • Set --server-url to the externally visible URL.
  • Set --trust-proxy-headers only behind a proxy that overwrites the header.
  • Leave rate limiting on, and leave --allow-unauthenticated off outside a lab.
  • Decide the tool surface with --scopes before worrying about the network: see Scopes and authorization and Security model.

Built 2026-09-19. Source: levantar-ai/secronyx. Found a mistake? Tell us.