Skip to content
NewsGitHubDownload

Controller security

The controller API (external-controller, external-controller-tls, external-controller-unix, external-controller-pipe) can change the config, the rules, the inbound users and restart or update the core. The secret is its only credential: every path except the health probes, the /ui files and the DoH path needs Authorization: Bearer <secret> (browser WebSockets use a one-time ticket; reusable ?token= secrets require an explicit compatibility option).

Every TCP controller requires a credential, including loopback. Plain HTTP external-controller is restricted to loopback by default; a non-loopback bearer secret would travel in clear text. Refused listeners log an error, and PUT /configs answers 400 and applies nothing.

Without a secret these work:

  • external-controller-unix and external-controller-pipe, protected by the file mode and the pipe ACL;
  • external-controller-tls with tls.client-auth-type: require-and-verify (or with tls.client-auth-cert set): the client certificate is the credential.

external-controller-allow-insecure: true (or -ext-ctl-allow-insecure, CLASH_OVERRIDE_EXTERNAL_CONTROLLER_ALLOW_INSECURE=true) is the explicit legacy opt-in for a secretless TCP controller or remote plaintext bearer HTTP. Use external-controller-tls for remote access.

Web pages and a controller without a secret

Section titled “Web pages and a controller without a secret”

A TCP controller without a secret exists only in explicit insecure mode or with verified TLS client certificates. The browser guard remains defense in depth for the latter:

  • answers only requests whose Host is an IP address or localhost;
  • refuses requests with an Origin header (all cross-origin requests and websockets from a browser) unless the origin is the controller itself (the bundled /ui) or is listed literally in external-controller-cors.allow-origins; "*" does not count.

Programs (scripts, panels, curl) send no Origin. A hosted dashboard such as MetaCubeXD needs a secret or verified client certificate, plus its origin in allow-origins when applicable. Explicit insecure mode turns this guard off.

PUT /configs, SIGHUP (systemctl reload citrus) and a restart apply the controller settings of the new config: secret, the addresses, tls, external-controller-cors, external-ui. When one of them changes, the controller listeners restart (the PUT /configs response is sent first) and the open websocket streams (/logs, /traffic, /memory, /connections, /events) are closed, so nothing authorized by a retired secret stays open. Command line overrides apply on every reload. Use -secret-file; literal -secret, -age-secret-key and -config are refused unless -allow-secret-arguments explicitly restores the process-list exposure. A reload that does not change the controller leaves its connections alone.

GET /version answers as mihomo does, with one more field:

{"meta": true, "version": "v1.0.0-beta.3", "core": "citrus"}

mihomo clients skip the extra field. An app that works with both cores can name the core by core, and take it for mihomo when the field is missing.

Every controller listener cuts off a client that has not sent its request headers (or finished the TLS handshake) in 10 seconds, closes idle keep-alive connections after 2 minutes and accepts at most 256 KiB of headers. The streams are not limited in time.

GET /connections as a websocket takes interval in milliseconds from 1 to 3600000; any other value is answered with 400.