No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
sam d005f6118d Refuse a line ending in a space inside caddy, raw or global
Such a line cannot stay a | block: yq rewrites the whole value as one
quoted string on a single line, and the entry becomes unreadable. Caddy
ignores those spaces, so nothing else gives them away, and show stops
colouring from that point because yq no longer prints the file.

The check names the entry, and runs on every path into the file, so a
value with one cannot be set either.

While testing it: a failed mkdir of a log directory still reported the
directory as created and carried on, because ensure_log_dirs runs from
build, whose errors the callers handle themselves.
2026-09-22 22:47:30 +02:00
cctl Refuse a line ending in a space inside caddy, raw or global 2026-09-22 22:47:30 +02:00
cctl.example.yaml Generate the Caddyfile from one YAML file 2026-09-22 21:18:45 +02:00
install Generate the Caddyfile from one YAML file 2026-09-22 21:33:13 +02:00
README.md Generate the Caddyfile from one YAML file 2026-09-22 21:33:13 +02:00

cctl

One YAML file describes every site a Caddy server serves; cctl turns it into the Caddyfile, validates it with caddy validate before replacing anything, and, if asked to, keeps a dnsmasq hosts file in step with the sites.

A site is written once, with the domains it is published on. cctl writes the block for each domain, the redirects that point to it, and the DNS lines that make its names resolve.

Requirements

  • bash 4.4 or later
  • yq by mikefarah, the Go one. The Python yq, a wrapper around jq, has a different syntax and does not work; cctl checks which one it finds.
  • caddy
  • OpenRC, for run, manual, start and stop
  • dnsmasq, only if the YAML has a dns section

Installation

install -m 755 cctl /usr/local/bin/cctl

The install script in this repository does the same over the network, and writes the tag and the commit count into the copy it installs, which cctl -v then reports. A copy run from the repository says dev.

The configuration is read from /etc/cctl.yaml and the Caddyfile is written to /etc/caddy/Caddyfile. Both can be changed with -c and -o, or once and for all in the constants at the top of the script, together with the account Caddy runs as (caddy) and the name of its service.

An example

global: |
  {
    email admin@example.com
  }

  (internal-ca) {
    tls {
      issuer acme https://ca.example.net/acme/acme/directory {
        trusted_roots /etc/caddy/ca.crt
      }
    }
  }

  (sso) {
    reverse_proxy /outpost.goauthentik.io/* http://192.0.2.50
    forward_auth {args[0]} http://192.0.2.50 {
      uri /outpost.goauthentik.io/auth/caddy
      copy_headers X-Authentik-Username X-Authentik-Groups
    }
  }

  (log) {
    log {
      output file /var/log/caddy/{args[0]}.log
    }
  }

dns:
  file: /etc/dnsmasq.d/cctl

domains:
  local:
    tls: internal-ca
    dns: 192.0.2.10

  example.com:

proxy:
  wiki:
    enable: true
    log: true
    domains: [local, example.com]
    sso: [example.com]
    redir: [w]
    to: 192.0.2.21:8080

sites:
  static:
    enable: true
    domains: [example.com]
    caddy: |
      root * /srv/www/static
      file_server

cctl config writes the global part as it is, followed by:

https://w {
	import internal-ca
	redir / https://wiki
}

https://wiki {
	import internal-ca
	import log wiki
	reverse_proxy 192.0.2.21:8080
}

https://w.example.com {
	redir / https://wiki.example.com
}

https://wiki.example.com {
	import log wiki.example.com
	route {
		import sso *
		reverse_proxy 192.0.2.21:8080
	}
}

https://static.example.com {
	root * /srv/www/static
	file_server
}

and /etc/dnsmasq.d/cctl:

192.0.2.10 wiki
192.0.2.10 w

cctl.example.yaml in this repository is longer, and covers every kind of site cctl can write.

The YAML file

Five top-level keys, all optional except where a site needs them.

global

Caddyfile text, written at the top of the Caddyfile unchanged: the global options block, the snippets the sites import, and anything else that is not a site. cctl imports three kinds of snippet by name, and they must be defined here:

  • the one named by a domain's tls
  • (sso), for sites with sso. It gets the path to protect as {args[0]}: * for the whole site.
  • (log), for sites with log: true. It gets the host of the block as {args[0]}, so each host can log to its own file.

dns

dns:
  file: /etc/dnsmasq.d/cctl

After every change cctl rewrites this file with one line per name, for the domains that have dns, and sends dnsmasq a SIGHUP, which makes it reread its hosts files without stopping answering. The file belongs to cctl: anything written in it by hand is lost on the next change. Names of your own go in a file of their own, with an addn-hosts line of their own in dnsmasq.conf.

Without this section cctl does not touch DNS; cctl hosts still prints the lines.

domains

The key is the domain name: a site wiki on example.com is served as wiki.example.com. local is the exception, for bare names with no domain at all: wiki.

Key What it does
tls snippet imported in every https block of the domain
dns address the domain's names resolve to in the dns file
proto http or https (default https)
follow another domain: every site on it is also on this one

A domain with no keys is written as just its name, like example.com: above.

follow publishes every site of one domain on another as well, including the sites added later. A site that should stay off lists the domain in exclude. It goes one level deep: a domain cannot follow one that follows another, so where a site's domains come from can always be read off the site and one domain entry.

domains:
  local:
    tls: internal-ca
  lan:
    follow: local
    tls: internal-ca

proxy and sites

Both hold sites, keyed by name. The two sections are only there to be read: proxy for sites that are a reverse_proxy to one upstream, sites for those with a body written by hand. A name is unique across both.

Key Section What it does
enable both true to generate the site; absent or false keeps it in the file only
log both true adds import log <host> to each block
proto sites http or https, over the domain's
domains both the domains the site is on
exclude both domains it would get through follow, and should not
sso both domains where the site is behind the sso snippet; domain/path protects only that path
redir both other names that redirect to the site, on each of its domains
to proxy the upstream of reverse_proxy
caddy both Caddyfile text for the body of the block
raw sites complete blocks, addresses included, written as they are

Keys are written back in this order by set, add and del.

The body of a block is the caddy text, then reverse_proxy <to>: in that order, because inside the route that sso adds directives run as written, and anything after reverse_proxy would never be reached.

A site with raw is written exactly as given, once, and only enable applies to it. It is for the case none of the keys can express; nothing in the example above needs it.

A site called *, quoted as "*" in YAML, stands for every name on a domain: on example.com it is https://*.example.com. It cannot be on local, and it has no line in the dns file, which cannot hold wildcards.

Commands

Command What it does
config generate, validate and install the Caddyfile
edit edit the YAML; it is saved only once it is valid
show [site] the blocks generated for a site; with no site, the YAML
list [domain] sites and the domains they are on, disabled ones last
hosts the DNS lines, printed
dns rewrite the dns file now
get <site> [key] a site, or one of its keys
set <site> <key> <value> set a key; - reads the value from stdin
add proxy|site <name> [key value ...] add a site
del <site> [key] delete a site, or one of its keys
get|set|add|del domain <name> ... the same, for a domain
run config, then Caddy in the foreground
manual Caddy in the foreground, without generating
start, stop the Caddy service

Flags: -c <file> for the YAML, -o <file> for the Caddyfile, -v for the version, -n for a dry run: config prints the Caddyfile, set, add and del print the diff of the YAML, and nothing is replaced.

cctl add proxy wiki domains local,example.com to 192.0.2.21:8080 enable true
cctl set wiki sso example.com
cctl set wiki enable false
cctl set static caddy - < static.caddy
cctl del wiki redir
cctl add domain lan follow local tls internal-ca

Lists are given comma-separated, enable and log as true or false. The shell expands * unless it is quoted: cctl set '*' enable false.

How a change is applied

Every change, from edit or from set, add and del, is made to a copy of the YAML. The copy is checked, turned into a Caddyfile and run through caddy validate; only then does it replace the YAML and the Caddyfile, and the dns file is rewritten. A change that would break the configuration is refused, with every problem found and the entry it is about, and nothing is written.

The checks are the rules the generator relies on: known keys and domains, true or false where a boolean is expected, lists where a list is, unique names including the ones in redir, a to for every proxy, at most one sso path per domain, an exclude that actually removes a domain.

As root, caddy validate runs as the account Caddy runs as, so a file Caddy cannot read fails here and not when the service starts. The directories of the log files are created first, owned by that account, since it cannot create them under /var/log itself.

edit opens a copy in $EDITOR (nano if unset). If the copy does not validate, the error is shown and the copy reopened with the error at the top as # cctl: comments, which are dropped again before saving; Ctrl+C gives up and leaves the YAML as it was.

set, add and del go through yq, and keep the file laid out as it was: blank lines between entries, comments, flow lists like [a, b].

Writing the Caddyfile does not touch the running Caddy: the new configuration takes effect on the next run or start. run and manual stop the service, keep Caddy in the foreground with its log on screen, and start the service again however Caddy exits, Ctrl+C included.