| title | API Documentation |
|---|---|
| description | Complete REST API reference for Charon. Includes endpoints for proxy hosts, certificates, security, and more. |
Charon REST API documentation. All endpoints return JSON and use standard HTTP status codes.
http://localhost:8080/api/v1
Sign in with POST /api/v1/auth/login. Charon sets an auth_token session cookie. API clients may instead send the token as a bearer header:
Authorization: Bearer <token>Public routes: POST /auth/login, GET /auth/verify, GET /auth/status, the setup and invite routes. Every other route requires a valid session. Some routes additionally require the admin role.
GET /auth/verify is the check Caddy calls for forward auth. It is reachable without the normal session middleware, but it checks the caller's session itself (401 when not signed in). It also needs a recognizable forwarded host (X-Forwarded-Host, or X-Original-Host when that is absent) and returns 403 when the host is missing, malformed, or not one of your proxy hosts. Hosts are matched exactly, ignoring upper/lower case and port numbers. A stored wildcard such as *.example.com covers exactly one extra label (app.example.com, but not a.b.example.com).
Audit entries record the actor as user:<id> for signed-in users, emergency for emergency-token requests, and the client address otherwise.
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Example",
"created_at": "2025-01-18T10:00:00Z"
}{
"error": "Resource not found",
"code": 404
}| Code | Description |
|---|---|
| 200 | Success |
| 201 | Created |
| 204 | No Content (successful deletion) |
| 400 | Bad Request (validation error) |
| 404 | Not Found |
| 500 | Internal Server Error |
Expose internal counters for scraping.
GET /metricsNo authentication required. Primary WAF metrics:
charon_waf_requests_total
charon_waf_blocked_total
charon_waf_monitored_total
Check API health status.
GET /healthResponse 200:
{
"status": "ok"
}GET /security/statusReturns enabled flag plus modes for each module.
GET /security/configResponse 200 (no config yet): { "config": null }
POST /security/config
Content-Type: application/jsonRequest Body (example):
{
"name": "default",
"enabled": true,
"admin_whitelist": "198.51.100.10,203.0.113.0/24",
"crowdsec_mode": "local",
"waf_mode": "monitor",
"waf_rules_source": "owasp-crs-local"
}Response 200: { "config": { ... } }
Security Considerations:
Webhook URLs configured in security settings are validated to prevent Server-Side Request Forgery (SSRF) attacks. The following destinations are blocked:
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Cloud metadata endpoints (169.254.169.254)
- Loopback addresses (127.0.0.0/8)
- Link-local addresses
Error Response:
{
"error": "Invalid webhook URL: URL resolves to a private IP address (blocked for security)"
}Example Valid URL:
{
"webhook_url": "https://webhook.example.com/receive"
}POST /security/enablePayload (optional break-glass token):
{ "break_glass_token": "abcd1234" }POST /security/disablePayload (required if not localhost):
{ "break_glass_token": "abcd1234" }POST /security/breakglass/generateResponse 200: { "token": "plaintext-token-once" }
GET /security/decisions?limit=50Response 200: { "decisions": [ ... ] }
POST /security/decisions
Content-Type: application/jsonPayload:
{ "ip": "203.0.113.5", "action": "block", "details": "manual temporary block" }GET /security/rulesetsResponse 200: { "rulesets": [ ... ] }
POST /security/rulesets
Content-Type: application/jsonPayload:
{
"name": "owasp-crs-quick",
"source_url": "https://example.com/owasp-crs.txt",
"mode": "owasp",
"content": "# raw rules"
}Response 200: { "ruleset": { ... } }
DELETE /security/rulesets/:idResponse 200: { "deleted": true }
Validates that a URL is properly formatted for use as the application's public URL.
POST /settings/validate-url
Content-Type: application/json
Authorization: Bearer <admin-token>Request Body:
{
"url": "https://charon.example.com"
}Required Fields:
url(string) - The URL to validate
Response 200 (Valid URL):
{
"valid": true,
"normalized": "https://charon.example.com"
}Response 200 (Valid with Warning):
{
"valid": true,
"normalized": "http://charon.example.com",
"warning": "Using http:// instead of https:// is not recommended for production environments"
}Response 400 (Invalid URL):
{
"valid": false,
"error": "URL must start with http:// or https:// and cannot include path components"
}Response 403:
{
"error": "Admin access required"
}Validation Rules:
- URL must start with
http://orhttps:// - URL cannot include path components (e.g.,
/admin) - Trailing slashes are automatically removed
- Port numbers are allowed (e.g.,
:8080) - Warning is returned if using
http://(insecure)
Examples:
# Valid HTTPS URL
curl -X POST http://localhost:8080/api/v1/settings/validate-url \
-H "Content-Type: application/json" \
-d '{"url": "https://charon.example.com"}'
# Valid with port
curl -X POST http://localhost:8080/api/v1/settings/validate-url \
-H "Content-Type: application/json" \
-d '{"url": "https://charon.example.com:8443"}'
# Invalid - no protocol
curl -X POST http://localhost:8080/api/v1/settings/validate-url \
-H "Content-Type: application/json" \
-d '{"url": "charon.example.com"}'
# Invalid - includes path
curl -X POST http://localhost:8080/api/v1/settings/validate-url \
-H "Content-Type: application/json" \
-d '{"url": "https://charon.example.com/admin"}'Generates a preview of the invite URL that would be sent to a user, without actually creating the invitation.
POST /users/preview-invite-url
Content-Type: application/json
Authorization: Bearer <admin-token>Request Body:
{
"email": "newuser@example.com"
}Required Fields:
email(string) - Email address for the preview
Response 200 (Configured):
{
"preview_url": "https://charon.example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW",
"base_url": "https://charon.example.com",
"is_configured": true,
"email": "newuser@example.com",
"warning": false,
"warning_message": ""
}Response 200 (Not Configured):
{
"preview_url": "http://localhost:8080/accept-invite?token=SAMPLE_TOKEN_PREVIEW",
"base_url": "http://localhost:8080",
"is_configured": false,
"email": "newuser@example.com",
"warning": true,
"warning_message": "Application URL not configured. The invite link may not be accessible from external networks."
}Response 400:
{
"error": "email is required"
}Response 403:
{
"error": "Admin access required"
}Field Descriptions:
preview_url- Complete invite URL with sample tokenbase_url- The base URL being used (configured or fallback)is_configured- Whether Application URL is configured in settingsemail- Email address from the request (echoed back)warning- Boolean indicating if there's a configuration warningwarning_message- Human-readable warning (empty if no warning)
Use Cases:
- Pre-flight check: Verify invite URLs before creating users
- Configuration validation: Confirm Application URL is set correctly
- UI preview: Show users what invite link will look like
- Testing: Validate invite flow without creating actual invitations
Examples:
# Preview invite URL
curl -X POST http://localhost:8080/api/v1/users/preview-invite-url \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com"}'
# Response when configured:
{
"preview_url": "https://charon.example.com/accept-invite?token=SAMPLE_TOKEN_PREVIEW",
"base_url": "https://charon.example.com",
"is_configured": true,
"email": "admin@example.com",
"warning": false,
"warning_message": ""
}JavaScript Example:
const previewInvite = async (email) => {
const response = await fetch('http://localhost:8080/api/v1/users/preview-invite-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer <admin-token>'
},
body: JSON.stringify({ email })
});
const data = await response.json();
if (data.warning) {
console.warn(data.warning_message);
console.log('Configure Application URL in System Settings');
} else {
console.log('Invite URL:', data.preview_url);
}
};
previewInvite('newuser@example.com');Python Example:
import requests
def preview_invite(email, api_base='http://localhost:8080/api/v1'):
response = requests.post(
f'{api_base}/users/preview-invite-url',
headers={'Content-Type': 'application/json'},
json={'email': email}
)
data = response.json()
if data.get('warning'):
print(f"Warning: {data['warning_message']}")
else:
print(f"Invite URL: {data['preview_url']}")
return data
preview_invite('admin@example.com')Resend an invitation email to a pending user. Generates a new invite token and sends it to the user's email address.
POST /users/:id/resend-invite
Authorization: Bearer <admin-token>Parameters:
id(path) - User ID (numeric)
Response 200:
{
"email_sent": true,
"invite_url": "https://charon.example.com/accept-invite?token=abc123...",
"expires_at": "2026-01-31T12:00:00Z"
}Response 400:
{
"error": "User is not in pending status"
}Response 403:
{
"error": "Admin access required"
}Response 404:
{
"error": "User not found"
}Use Cases:
- User didn't receive the original invitation email
- Invite token has expired and needs renewal
- User lost or deleted the invitation email
Example:
curl -X POST http://localhost:8080/api/v1/users/42/resend-invite \
-H "Authorization: Bearer <admin-token>"JavaScript Example:
const resendInvite = async (userId) => {
const response = await fetch(`http://localhost:8080/api/v1/users/${userId}/resend-invite`, {
method: 'POST',
headers: {
'Authorization': 'Bearer <admin-token>'
}
});
const data = await response.json();
if (data.email_sent) {
console.log('Invitation resent successfully');
} else {
console.log('New invite created, but email could not be sent');
console.log('Invite URL:', data.invite_url);
}
return data;
};
resendInvite(42);Update a user's name, email, role, enabled state, or password.
PUT /users/:id
Content-Type: application/json
Authorization: Bearer <token>Request Body (all fields optional):
{
"name": "Jane Doe",
"password": "a-new-password",
"current_password": "the-old-password"
}Password changes:
- Changing your own password requires
current_password. - An administrator resetting another user's password does not need it.
- Any password change ends that user's existing sessions. If you change your own password this way, you are signed out and need to sign in again.
Test if a URL is reachable from the server with comprehensive SSRF (Server-Side Request Forgery) protection.
POST /settings/test-url
Content-Type: application/json
Authorization: Bearer <admin-token>Request Body:
{
"url": "https://api.example.com"
}Required Fields:
url(string) - The URL to test for connectivity
Response 200 (Reachable):
{
"reachable": true,
"latency": 145,
"message": "URL is reachable",
"error": ""
}Response 200 (Unreachable):
{
"reachable": false,
"latency": 0,
"message": "",
"error": "connection timeout after 5s"
}Response 400 (Invalid URL):
{
"error": "invalid URL format"
}Response 403 (Security Block):
{
"error": "URL resolves to a private IP address (blocked for security)",
"details": "SSRF protection: private IP ranges are not allowed"
}Response 403 (Admin Required):
{
"error": "Admin access required"
}Field Descriptions:
reachable- Boolean indicating if the URL is accessiblelatency- Response time in milliseconds (0 if unreachable)message- Success message describing the resulterror- Error message if the test failed (empty on success)
Security Features:
This endpoint implements comprehensive SSRF protection:
- DNS Resolution Validation - Resolves hostname with 3-second timeout
- Private IP Blocking - Blocks 13+ CIDR ranges:
- RFC 1918 private networks (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) - Loopback addresses (
127.0.0.0/8,::1/128) - Link-local addresses (
169.254.0.0/16,fe80::/10) - IPv6 Unique Local Addresses (
fc00::/7) - Multicast and other reserved ranges
- RFC 1918 private networks (
- Cloud Metadata Protection - Blocks AWS (
169.254.169.254) and GCP (metadata.google.internal) metadata endpoints - Controlled HTTP Request - HEAD request with 5-second timeout
- Limited Redirects - Maximum 2 redirects allowed
- Admin-Only Access - Requires authenticated admin user
Use Cases:
- Webhook validation: Verify webhook endpoints before saving
- Application URL testing: Confirm configured URLs are reachable
- Integration setup: Test external service connectivity
- Health checks: Verify upstream service availability
Examples:
# Test a public URL
curl -X POST http://localhost:8080/api/v1/settings/test-url \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <admin-token>" \
-d '{"url": "https://api.github.com"}'
# Response:
{
"reachable": true,
"latency": 152,
"message": "URL is reachable",
"error": ""
}
# Attempt to test a private IP (blocked)
curl -X POST http://localhost:8080/api/v1/settings/test-url \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <admin-token>" \
-d '{"url": "http://192.168.1.1"}'
# Response:
{
"error": "URL resolves to a private IP address (blocked for security)",
"details": "SSRF protection: private IP ranges are not allowed"
}JavaScript Example:
const testURL = async (url) => {
const response = await fetch('http://localhost:8080/api/v1/settings/test-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer <admin-token>'
},
body: JSON.stringify({ url })
});
const data = await response.json();
if (data.reachable) {
console.log(`✓ ${url} is reachable (${data.latency}ms)`);
} else {
console.error(`✗ ${url} failed: ${data.error}`);
}
return data;
};
testURL('https://api.example.com');Python Example:
import requests
def test_url(url, api_base='http://localhost:8080/api/v1'):
response = requests.post(
f'{api_base}/settings/test-url',
headers={
'Content-Type': 'application/json',
'Authorization': 'Bearer <admin-token>'
},
json={'url': url}
)
data = response.json()
if response.status_code == 403:
print(f"Security block: {data.get('error')}")
elif data.get('reachable'):
print(f"✓ {url} is reachable ({data['latency']}ms)")
else:
print(f"✗ {url} failed: {data['error']}")
return data
test_url('https://api.github.com')Security Considerations:
- Only admin users can access this endpoint
- Private IPs and cloud metadata endpoints are always blocked
- DNS rebinding attacks are prevented by resolving before the HTTP request
- Request timeouts prevent slowloris-style attacks
- Limited redirects prevent redirect loops and excessive resource consumption
- Consider rate limiting this endpoint in production environments
GET /certificatesResponse 200:
[
{
"id": 1,
"uuid": "cert-uuid-123",
"name": "My Custom Cert",
"provider": "custom",
"domains": "example.com, www.example.com",
"expires_at": "2026-01-01T00:00:00Z",
"created_at": "2025-01-01T10:00:00Z"
}
]POST /certificates/upload
Content-Type: multipart/form-dataRequest Body:
name(required) - Certificate namecertificate_file(required) - Certificate file (.crt or .pem)key_file(required) - Private key file (.key or .pem)
Response 201:
{
"id": 1,
"uuid": "cert-uuid-123",
"name": "My Custom Cert",
"provider": "custom",
"domains": "example.com"
}Delete a certificate. Requires that the certificate is not currently in use by any proxy hosts.
DELETE /certificates/:idParameters:
id(path) - Certificate ID (numeric)
Response 200:
{
"message": "certificate deleted"
}Response 400:
{
"error": "invalid id"
}Response 409:
{
"error": "certificate is in use by one or more proxy hosts"
}Response 500:
{
"error": "failed to delete certificate"
}Note: A backup is automatically created before deletion. The certificate files are removed from disk along with the database record.
Download a certificate, optionally with its private key, as a file.
POST /certificates/:uuid/exportParameters:
uuid(path) - Certificate UUID
Request Body:
format- Export file formatinclude_key(boolean) - Include the private key in the exportpfx_password- Password to protect the exported PFX file (PFX format only)password- Your own account password (required wheninclude_keyistrue)
Response 200: The certificate file as a download.
Response 403: Returned when include_key is true and the password is missing or wrong, or the caller is not an admin.
Notes:
- Exporting a private key (
include_key: true) requires an admin account and that admin's own account password. - A wrong or missing password is rejected with 403. Each password check counts against the
loginrate limit, and once that limit is reached the endpoint returns 429. - Emergency-bypass sessions cannot export private keys, but they can still export certificates without the key.
GET /proxy-hostsResponse 200:
[
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"domain": "example.com, www.example.com",
"forward_scheme": "http",
"forward_host": "localhost",
"forward_port": 8080,
"ssl_forced": false,
"http2_support": true,
"hsts_enabled": false,
"hsts_subdomains": false,
"block_exploits": true,
"websocket_support": false,
"enabled": true,
"enable_standard_headers": true,
"remote_server_id": null,
"created_at": "2025-01-18T10:00:00Z",
"updated_at": "2025-01-18T10:00:00Z"
}
]GET /proxy-hosts/:uuidParameters:
uuid(path) - Proxy host UUID
Response 200:
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"domain": "example.com",
"forward_scheme": "https",
"forward_host": "backend.internal",
"forward_port": 9000,
"ssl_forced": true,
"websocket_support": false,
"enabled": true,
"enable_standard_headers": true,
"created_at": "2025-01-18T10:00:00Z",
"updated_at": "2025-01-18T10:00:00Z"
}Response 404:
{
"error": "Proxy host not found"
}POST /proxy-hosts
Content-Type: application/jsonRequest Body:
{
"domain": "new.example.com",
"forward_scheme": "http",
"forward_host": "localhost",
"forward_port": 3000,
"ssl_forced": false,
"http2_support": true,
"hsts_enabled": false,
"hsts_subdomains": false,
"block_exploits": true,
"websocket_support": false,
"enabled": true,
"enable_standard_headers": true,
"remote_server_id": null
}Required Fields:
domain- Domain name(s), comma-separatedforward_host- Target hostname or IPforward_port- Target port number
Optional Fields:
forward_scheme- Default:"http"ssl_forced- Default:falsehttp2_support- Default:truehsts_enabled- Default:falsehsts_subdomains- Default:falseblock_exploits- Default:truewebsocket_support- Default:falseenabled- Default:trueenable_standard_headers- Default:true(for new hosts),false(for existing hosts migrated from older versions)- When
true: Adds X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port headers - When
false: Old behavior (headers only added for WebSocket or application-specific needs)
- When
remote_server_id- Default:null
Response 201:
{
"uuid": "550e8400-e29b-41d4-a716-446655440001",
"domain": "new.example.com",
"forward_scheme": "http",
"forward_host": "localhost",
"enable_standard_headers": true,
"forward_port": 3000,
"created_at": "2025-01-18T10:05:00Z",
"updated_at": "2025-01-18T10:05:00Z"
}Response 400:
{
"error": "domain is required"
}PUT /proxy-hosts/:uuid
Content-Type: application/jsonParameters:
uuid(path) - Proxy host UUID
Request Body: (all fields optional)
{
"domain": "updated.example.com",
"forward_port": 8081,
"ssl_forced": true
}Response 200:
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"domain": "updated.example.com",
"forward_port": 8081,
"ssl_forced": true,
"updated_at": "2025-01-18T10:10:00Z"
}DELETE /proxy-hosts/:uuidParameters:
uuid(path) - Proxy host UUID
Response 204: No content
Response 404:
{
"error": "Proxy host not found"
}GET /remote-serversQuery Parameters:
enabled(optional) - Filter by enabled status (trueorfalse)
Response 200:
[
{
"uuid": "660e8400-e29b-41d4-a716-446655440000",
"name": "Docker Registry",
"provider": "docker",
"host": "registry.local",
"port": 5000,
"reachable": true,
"last_checked": "2025-01-18T09:55:00Z",
"enabled": true,
"created_at": "2025-01-18T09:00:00Z",
"updated_at": "2025-01-18T09:55:00Z"
}
]GET /remote-servers/:uuidParameters:
uuid(path) - Remote server UUID
Response 200:
{
"uuid": "660e8400-e29b-41d4-a716-446655440000",
"name": "Docker Registry",
"provider": "docker",
"host": "registry.local",
"port": 5000,
"reachable": true,
"enabled": true
}POST /remote-servers
Content-Type: application/jsonRequest Body:
{
"name": "Production API",
"provider": "generic",
"host": "api.prod.internal",
"port": 8080,
"enabled": true
}Required Fields:
name- Server namehost- Hostname or IPport- Port number
Optional Fields:
provider- One of:generic,docker,kubernetes,aws,gcp,azure(default:generic)enabled- Default:true
Response 201:
{
"uuid": "660e8400-e29b-41d4-a716-446655440001",
"name": "Production API",
"provider": "generic",
"host": "api.prod.internal",
"port": 8080,
"reachable": false,
"enabled": true,
"created_at": "2025-01-18T10:15:00Z"
}PUT /remote-servers/:uuid
Content-Type: application/jsonRequest Body: (all fields optional)
{
"name": "Updated Name",
"port": 8081,
"enabled": false
}Response 200:
{
"uuid": "660e8400-e29b-41d4-a716-446655440000",
"name": "Updated Name",
"port": 8081,
"enabled": false,
"updated_at": "2025-01-18T10:20:00Z"
}DELETE /remote-servers/:uuidResponse 204: No content
Test connectivity to a remote server.
POST /remote-servers/:uuid/testParameters:
uuid(path) - Remote server UUID
Response 200:
{
"reachable": true,
"address": "registry.local:5000",
"timestamp": "2025-01-18T10:25:00Z"
}Response 200 (unreachable):
{
"reachable": false,
"address": "offline.server:8080",
"error": "connection timeout",
"timestamp": "2025-01-18T10:25:00Z"
}Note: This endpoint updates the reachable and last_checked fields on the remote server.
Connect to a WebSocket stream of live security logs. This endpoint uses WebSocket protocol for real-time bidirectional communication.
GET /api/v1/logs/live
Upgrade: websocketQuery Parameters:
level(optional) - Filter by log level. Values:debug,info,warn,errorsource(optional) - Filter by log source. Values:cerberus,waf,crowdsec,acl
WebSocket Connection:
const ws = new WebSocket('ws://localhost:8080/api/v1/logs/live?source=cerberus&level=error');
ws.onmessage = (event) => {
const logEntry = JSON.parse(event.data);
console.log(logEntry);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('Connection closed');
};Message Format:
Each message received from the WebSocket is a JSON-encoded LogEntry:
{
"level": "error",
"message": "WAF blocked request from 203.0.113.42",
"timestamp": "2025-12-09T10:30:45Z",
"source": "waf",
"fields": {
"ip": "203.0.113.42",
"rule_id": "942100",
"request_uri": "/api/users?id=1' OR '1'='1",
"severity": "CRITICAL"
}
}Field Descriptions:
level- Log severity:debug,info,warn,errormessage- Human-readable log messagetimestamp- ISO 8601 timestamp (RFC3339 format)source- Component that generated the log (e.g.,cerberus,waf,crowdsec)fields- Additional structured data specific to the event type
Connection Lifecycle:
- Server sends a ping every 30 seconds to keep connection alive
- Client should respond to pings or connection may timeout
- Server closes connection if client stops reading
- Client can close connection by calling
ws.close()
Error Handling:
- If upgrade fails, returns HTTP 400 with error message
- Authentication required (when auth is implemented)
- Rate limiting applies (when rate limiting is implemented)
Example: Filter for critical WAF events only
const ws = new WebSocket('ws://localhost:8080/api/v1/logs/live?source=waf&level=error');Retrieve current security notification settings.
GET /api/v1/security/notifications/settingsResponse 200:
{
"enabled": true,
"min_log_level": "warn",
"notify_waf_blocks": true,
"notify_acl_denials": true,
"notify_rate_limit_hits": false,
"webhook_url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX",
"email_recipients": "admin@example.com,security@example.com"
}Field Descriptions:
enabled- Master toggle for all notificationsmin_log_level- Minimum severity to trigger notifications. Values:debug,info,warn,errornotify_waf_blocks- Send notifications for WAF blocking eventsnotify_acl_denials- Send notifications for ACL denial eventsnotify_rate_limit_hits- Send notifications for rate limit violationswebhook_url(optional) - URL to POST webhook notifications (Discord, Slack, etc.)email_recipients(optional) - Comma-separated list of email addresses
Response 404:
{
"error": "Notification settings not configured"
}Update security notification settings. All fields are optional—only provided fields are updated.
PUT /api/v1/security/notifications/settings
Content-Type: application/jsonRequest Body:
{
"enabled": true,
"min_log_level": "error",
"notify_waf_blocks": true,
"notify_acl_denials": false,
"notify_rate_limit_hits": false,
"webhook_url": "https://discord.com/api/webhooks/123456789/abcdefgh",
"email_recipients": "alerts@example.com"
}Security Considerations:
Webhook URLs are validated to prevent SSRF attacks. Blocked destinations:
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Cloud metadata endpoints (169.254.169.254)
- Loopback addresses (127.0.0.0/8)
- Link-local addresses
Error Response:
{
"error": "Invalid webhook URL: URL resolves to a private IP address (blocked for security)"
}All fields optional:
enabled(boolean) - Enable/disable all notificationsmin_log_level(string) - Must be one of:debug,info,warn,errornotify_waf_blocks(boolean) - Toggle WAF block notificationsnotify_acl_denials(boolean) - Toggle ACL denial notificationsnotify_rate_limit_hits(boolean) - Toggle rate limit notificationswebhook_url(string) - Webhook endpoint URLemail_recipients(string) - Comma-separated email addresses
Response 200:
{
"message": "Settings updated successfully"
}Response 400:
{
"error": "Invalid min_log_level. Must be one of: debug, info, warn, error"
}Response 500:
{
"error": "Failed to update settings"
}Example: Enable notifications for critical errors only
curl -X PUT http://localhost:8080/api/v1/security/notifications/settings \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"min_log_level": "error",
"notify_waf_blocks": true,
"webhook_url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
}'Webhook Payload Format:
When notifications are triggered, Charon sends a POST request to the configured webhook URL:
{
"event_type": "waf_block",
"severity": "error",
"timestamp": "2025-12-09T10:30:45Z",
"message": "WAF blocked SQL injection attempt",
"details": {
"ip": "203.0.113.42",
"rule_id": "942100",
"request_uri": "/api/users?id=1' OR '1'='1",
"user_agent": "curl/7.68.0"
}
}Check if there's an active import session.
GET /import/statusResponse 200 (no session):
{
"has_pending": false
}Response 200 (active session):
{
"has_pending": true,
"session": {
"uuid": "770e8400-e29b-41d4-a716-446655440000",
"filename": "Caddyfile",
"state": "reviewing",
"created_at": "2025-01-18T10:30:00Z",
"updated_at": "2025-01-18T10:30:00Z"
}
}Get preview of hosts to be imported (only available when session state is reviewing).
GET /import/previewResponse 200:
{
"hosts": [
{
"domain": "example.com",
"forward_host": "localhost",
"forward_port": 8080,
"forward_scheme": "http"
},
{
"domain": "api.example.com",
"forward_host": "backend",
"forward_port": 9000,
"forward_scheme": "https"
}
],
"conflicts": [
"example.com already exists"
],
"errors": []
}Response 404:
{
"error": "No active import session"
}Upload a Caddyfile for import.
POST /import/upload
Content-Type: application/jsonRequest Body:
{
"content": "example.com {\n reverse_proxy localhost:8080\n}",
"filename": "Caddyfile"
}Required Fields:
content- Caddyfile content
Optional Fields:
filename- Original filename (default:"Caddyfile")
Response 201:
{
"session": {
"uuid": "770e8400-e29b-41d4-a716-446655440000",
"filename": "Caddyfile",
"state": "parsing",
"created_at": "2025-01-18T10:35:00Z"
}
}Response 400:
{
"error": "content is required"
}Upload multiple Caddyfiles in a single request. Useful for importing configurations from multiple site files.
POST /import/upload-multi
Content-Type: application/jsonRequest Body:
{
"files": [
{
"filename": "example.com.Caddyfile",
"content": "example.com {\n reverse_proxy localhost:8080\n}"
},
{
"filename": "api.example.com.Caddyfile",
"content": "api.example.com {\n reverse_proxy localhost:9000\n}"
}
]
}Required Fields:
files- Array of file objects (minimum 1)filename(string, required) - Original filenamecontent(string, required) - Caddyfile content
Response 200:
{
"hosts": [
{
"domain": "example.com",
"forward_host": "localhost",
"forward_port": 8080,
"forward_scheme": "http"
},
{
"domain": "api.example.com",
"forward_host": "localhost",
"forward_port": 9000,
"forward_scheme": "http"
}
],
"conflicts": [],
"errors": [],
"warning": ""
}Response 400 (validation error):
{
"error": "files is required and must contain at least one file"
}Response 400 (parse error with warning):
{
"error": "Caddyfile uses file_server which is not supported for import",
"warning": "file_server directive detected - static file serving is not supported"
}TypeScript Example:
interface CaddyFile {
filename: string;
content: string;
}
const uploadCaddyfilesMulti = async (files: CaddyFile[]): Promise<ImportPreview> => {
const { data } = await client.post<ImportPreview>('/import/upload-multi', { files });
return data;
};
// Usage
const files = [
{ filename: 'site1.Caddyfile', content: 'site1.com { reverse_proxy :8080 }' },
{ filename: 'site2.Caddyfile', content: 'site2.com { reverse_proxy :9000 }' }
];
const preview = await uploadCaddyfilesMulti(files);Commit the import after resolving conflicts.
POST /import/commit
Content-Type: application/jsonRequest Body:
{
"session_uuid": "770e8400-e29b-41d4-a716-446655440000",
"resolutions": {
"example.com": "overwrite",
"api.example.com": "keep"
}
}Required Fields:
session_uuid- Active import session UUIDresolutions- Map of domain to resolution strategy
Resolution Strategies:
"keep"- Keep existing configuration, skip import"overwrite"- Replace existing with imported configuration"skip"- Same as keep
Response 200:
{
"imported": 2,
"skipped": 1,
"failed": 0
}Response 400:
{
"error": "Invalid session or unresolved conflicts"
}Cancel an active import session.
DELETE /import/cancel?session_uuid=770e8400-e29b-41d4-a716-446655440000Query Parameters:
session_uuid- Active import session UUID
Response 204: No content
Two independent limiters can answer 429 Too Many Requests. Both use the same response:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json; charset=utf-8
{"error":"Too many requests. Please wait before trying again."}Retry-After is an integer number of seconds (at least 1). The body never echoes request data.
A per-client token bucket in front of /api/v1/auth/* and every route that verifies an account password.
| Class | Routes | Default budget |
|---|---|---|
login |
POST /auth/login, POST /auth/change-password, POST /user/profile (email change only), POST /certificates/:uuid/export (with include_key) |
10 requests per 600 s (burst 10, one token per 60 s) |
session |
POST /auth/refresh, GET /auth/status, any future /auth route |
60 requests per 60 s |
All password verifications from one client share the login bucket. GET /auth/verify, GET /auth/me, POST /auth/logout, GET /auth/accessible-hosts and GET /auth/check-host/:hostId are not throttled. Valid emergency bypass requests, /api/v1/emergency/* and the Tier-2 server are never throttled.
Clients are identified by the resolved client address (IPv4 per address, IPv6 per /64). Behind a reverse proxy, configure CHARON_TRUSTED_PROXIES so the real client is used; see Trusted Proxies. Budgets are configurable through the CHARON_AUTH_RATELIMIT_* environment variables; see Login Protection. State is in memory and resets on restart.
The Cerberus rate limiter, off by default, defaults to 100 requests per 60 s with a burst of 20 per client when enabled. Validated administrator requests to /api/v1/security, /api/v1/settings and /api/v1/config are exempt.
GET /security/login-protectionAdmin only. Returns the effective settings and how Charon sees the caller.
{
"enabled": true,
"login": { "requests": 10, "window_seconds": 600 },
"session": { "requests": 60, "window_seconds": 60 },
"trusted_proxy_count": 1,
"caller_client_key": "203.0.113.7",
"caller_client_scope": "public",
"untrusted_forwarded_headers": {
"local": { "count": 3, "last_seen": "2026-09-24T10:11:12Z", "last_peer": "172.18.0.5", "last_peer_scope": "private" },
"public": { "count": 0, "last_seen": null, "last_peer": "", "last_peer_scope": "" }
}
}trusted_proxy_countis the number of entries in the effective (validated) trusted-proxy list.caller_client_keyandcaller_client_scope(loopback,privateorpublic) show the client identity Charon uses for the current request.untrusted_forwarded_headerscounts requests that carriedX-Forwarded-FororX-Real-IPfrom a peer that is not a trusted proxy, kept separately for local (loopback or private) and public peers.last_seenisnullwhilecountis 0.
🚧 Pagination is not yet implemented.
Future pagination:
GET /proxy-hosts?page=1&per_page=20🚧 Advanced filtering is not yet implemented.
Future filtering:
GET /proxy-hosts?enabled=true&sort=created_at&order=desc🚧 Webhooks are not yet implemented.
Future webhook events:
proxy_host.createdproxy_host.updatedproxy_host.deletedremote_server.unreachableimport.completed
No official SDKs yet. The API follows REST conventions and can be used with any HTTP client.
const API_BASE = 'http://localhost:8080/api/v1';
// List proxy hosts
const hosts = await fetch(`${API_BASE}/proxy-hosts`).then(r => r.json());
// Create proxy host
const newHost = await fetch(`${API_BASE}/proxy-hosts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: 'example.com',
forward_host: 'localhost',
forward_port: 8080
})
}).then(r => r.json());
// Test remote server
const testResult = await fetch(`${API_BASE}/remote-servers/${uuid}/test`, {
method: 'POST'
}).then(r => r.json());import requests
API_BASE = 'http://localhost:8080/api/v1'
# List proxy hosts
hosts = requests.get(f'{API_BASE}/proxy-hosts').json()
# Create proxy host
new_host = requests.post(f'{API_BASE}/proxy-hosts', json={
'domain': 'example.com',
'forward_host': 'localhost',
'forward_port': 8080
}).json()
# Test remote server
test_result = requests.post(f'{API_BASE}/remote-servers/{uuid}/test').json()For API issues or questions:
- GitHub Issues: https://github.com/Wikid82/charon/issues
- Discussions: https://github.com/Wikid82/charon/discussions