Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 62 additions & 75 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,119 +1,106 @@
# acmeproxy.pl
Easy to install and use proxy server for ACME DNS challenges written in perl

Utilizes [acme.sh](https://github.com/acmesh-official/acme.sh) to solve ACME DNS challenges for hosts on an internal network.
Proxy server for ACME DNS-01 challenges, with native support in [acme.sh](https://github.com/acmesh-official/acme.sh), [Caddy](https://caddyserver.com), and [Traefik](https://traefik.io)

## tl;dr
- Possess a domain name hosted on a DNS provider supported by the acme.sh [dnsapi](https://github.com/acmesh-official/acme.sh/wiki/dnsapi)
- Configure your internal DNS to locally serve records such as pictures.int.example.com pointing at the internal IP of your services
- Setup acmeproxy.pl and give it access to your DNS provider's API.
- Use acme.sh on internal hosts to request and maintain TLS certificates for *.int.example.com hostnames via acmeproxy
- Set up acmeproxy.pl and give it access to your DNS provider's API.
- Use acme.sh, Caddy, or Traefik on internal hosts to request and maintain TLS certificates for *.int.example.com hostnames via acmeproxy

Shebam! You now have TLS certificates for your internal services that have been signed by a trusted CA. https:// will Just Work from every device.
You now have TLS certificates for your internal services that have been signed by a trusted CA. https:// will Just Work from every device.

## Why?
acmeproxy.pl was written to make it easier and safer to automatically issue per-service [Let's Encrypt](https://letsencrypt.org) or [ZeroSSL](https://zerossl.com/) TLS certificates on an internal network.
Internal hosts need per-service TLS certificates, but giving every host direct DNS API credentials is a major security risk. acmeproxy.pl solves this by acting as an authenticated proxy. It centrally holds the sensitive DNS credentials and restricts access, ensuring users can only request certificates for their specifically authorized hostnames.

There are three main ways to handle internal TLS certificates:
- Run a certificate authority. This is good for enterprises but probably overkill for smaller setups.
## Install
Install dependencies:
- debian-ish: ```apt install libmojolicious-perl curl```
- others: install curl and cpanminus. run ```cpanm Mojolicious```

- Use something like certbot to generate certificates on a central host, then distribute the certificates to every host on the network. This can be error prone and difficult to orchestrate.

- Allow individual hosts to manage their own certificates by providing access to the DNS API for acme challenges. This is convenient but a massive security risk as every host will have unfettered access to the DNS API.
Download acmeproxy.pl:
```bash
curl -O https://raw.githubusercontent.com/madcamel/acmeproxy.pl/master/acmeproxy.pl
chmod +x acmeproxy.pl
```

Run `./acmeproxy.pl` directly to generate `acmeproxy.pl.conf`. Edit it, then run `./acmeproxy.pl` again. For initial testing it's best to run it directly so you can see the output. If it generates its own TLS certificate you've configured the DNS provider correctly.

As a fourth solution acmeproxy.pl provides the following:
- Allow internal non internet-exposed hosts to easily request TLS certificates using acme.sh
- Only the acmeproxy.pl service requires access to the DNS credentials, not all hosts
- Fine grained access control by tying credentials to allowed certificate hostnames
- Centralized service for logging and audit purposes
- Installs and manages its own TLS cetrificate via acme.sh
- Easy to use, few dependencies
For normal use, acmeproxy.pl manages its own process and logs to `acmeproxy.log`:
```bash
./acmeproxy.pl start # start in background
./acmeproxy.pl stop # stop
./acmeproxy.pl reload # restart (e.g. after editing config)
./acmeproxy.pl status # check if running
./acmeproxy.pl check # restart if dead; suitable for cron
```

## Install
Install dependencies:
- debian-ish: ```apt install libmojolicious-perl curl```
- others: install curl and cpanminus. run ```cpanm Mojolicious```
To have it restart automatically if it dies, add a crontab entry:
```
*/5 * * * * /path/to/acmeproxy.pl check >/dev/null 2>&1
@reboot /path/to/acmeproxy.pl check >/dev/null 2>&1
```

Or just run it in tmux like some sort of heathen.

Download acmeproxy.pl
acmeproxy.pl does not require a restart when acme.sh renews its TLS certificate.

## Usage

### Using acme.sh with acmeproxy
Sample acme.sh usage:
```bash
curl -O https://raw.githubusercontent.com/madcamel/acmeproxy.pl/master/acmeproxy.pl; chmod +x acmeproxy.pl
ACMEPROXY_ENDPOINT="https://acmeproxy.int.example.com:9443" \
ACMEPROXY_USERNAME="bob" ACMEPROXY_PASSWORD="dobbs" \
acme.sh --log --issue dns dns_acmeproxy -d bob.int.example.com
```
You will then want to install the certificate with something like:
```bash
acme.sh --log --install-cert -d bob.int.example.com --key-file /etc/nginx/bob.key --fullchain-file /etc/nginx/bob.crt --reloadcmd "systemctl reload nginx.service"
```
run ./acmeproxy.pl to generate a the acmeproxy.pl.conf configuration file.

Edit the configuration then run ./acmeproxy.pl again. If it is able to generate it's own TLS certificate you probably have configured the DNS provider correctly.
See `acme.sh --help install-cert` for the full list of `--reloadcmd` and deploy-hook options.

To daemonize:
- use systemd
- OR ```nohup ./acmeproxy.pl >>acmeproxy.log 2>&1 &```
- OR ```hypnotoad acmeproxy.pl```
- OR run it in tmux like some sort of heathen
Traefik supports acmeproxy via the ['httpreq'](https://doc.traefik.io/traefik/v3.3/https/acme/#providers) provider.
Caddy supports acmeproxy via the ['acmeproxy'](https://caddyserver.com/docs/json/admin/identity/issuers/acme/challenges/dns/provider/acmeproxy) provider

## Docker

To use the tool with docker you have 2 options: docker CLI or docker compose.

For docker compose you can use the following file as a reference:
**docker compose:**
```yaml
# $schema: "https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json"
name: "acmeproxy"

services:
acmeproxy:
image: ghcr.io/madcamel/acmeproxy.pl
restart: unless-stopped
port: # Or use expose when using a reverse proxy
- 9443/tcp
ports:
- "9443:9443"
volumes:
- ./config:/config:rw
# Optionally store the generated certificate data on a persistent volume
# - ./cert-data:/cert-data:rw
```

Note that if you want to store the generated certificate data on a persistant volume, you should add something like the following to your `acmeproxy.pl.conf` file:
```perl
# Extra params to pass when invoking acme.sh --install
acmesh_extra_params_install => [
'--config-home /cert-data',
],

# Extra params to pass when invoking acme.sh --install-cert
acmesh_extra_params_install_cert => [
'--config-home /cert-data',
],

# Extra params to pass when invoking acme.sh --issue
acmesh_extra_params_issue => [
'--config-home /cert-data',
],

# The directory to store acmeproxy.pl.crt and acmeproxy.pl.key
keypair_directory => '/cert-data',
```

Use the Docker CLI you can achive something similar using:
**docker CLI:**
```console
docker run \
-p 9443/tcp \
docker run -d \
-p 9443:9443 \
-v /path/to/config:/config:rw \
--restart unless-stopped \
ghcr.io/madcamel/acmeproxy.pl
```

### Using acme.sh with acmeproxy
Sample acme.sh usage:
```bash
ACMEPROXY_ENDPOINT="https://acmeproxy.int.example.com:9443" \
ACMEPROXY_USERNAME="bob" ACMEPROXY_PASSWORD="dobbs" \
acme.sh --log --issue dns dns_acmeproxy -d bob.int.example.com
```
You will then want to install the certificate with something like:
```bash
acme.sh --log --install-cert -d bob.int.example.com --key-file /etc/nginx/bob.key --fullchain-file /etc/nginx/bob.crt --reloadcmd "systemctl reload nginx.service"
```
If you're using a reverse proxy, replace `ports` with `expose` in compose (or drop `-p` from the CLI command).

### Persistent certificate storage

Without persistence, every container restart triggers a fresh ACME issuance. Let's Encrypt caps duplicate certificates at 5 per week. To persist certificate data, add a volume mount (`-v /path/to/cert-data:/cert-data:rw` or the compose equivalent) and add to `acmeproxy.pl.conf`:

This is not always the best way to do things. Please refer to the acme.sh documentation.
```perl
acmesh_extra_params_install => ['--config-home /cert-data'],
acmesh_extra_params_install_cert => ['--config-home /cert-data'],
acmesh_extra_params_issue => ['--config-home /cert-data'],
keypair_directory => '/cert-data',
```

## Security Notes
acmeproxy.pl was written to be run within an internal network. It's not recommended to expose your acmeproxy.pl host to the outside world.
Expand Down
48 changes: 48 additions & 0 deletions acmeproxy.pl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,42 @@
use Cwd;
use strict;

my $pidfile = cwd().'/acmeproxy.pid';
my $logfile = cwd().'/acmeproxy.log';

if (@ARGV) {
my $cmd = $ARGV[0];
if ($cmd eq 'stop') {
do_stop(); exit;
} elsif ($cmd eq 'status') {
my $pid = is_running();
say $pid ? "running (pid $pid)" : "not running"; exit;
} elsif ($cmd eq 'check') {
exit 0 if is_running();
print "not running, restarting\n";
unlink $pidfile;
exec($^X, $0, 'start') or die "exec failed: $!";
} elsif ($cmd eq 'reload') {
do_stop(); sleep 1;
exec($^X, $0, 'start') or die "exec failed: $!";
} elsif ($cmd eq 'start') {
if (my $pid = is_running()) {
print "already running (pid $pid)\n"; exit 1;
}
shift @ARGV;
my $child = fork() // die "fork: $!";
if ($child) {
open(my $pf, '>', $pidfile) or die "pidfile: $!";
print $pf $child; close $pf;
print "started (pid $child)\n";
exit 0;
}
POSIX::setsid();
open(STDOUT, '>>', $logfile) or die;
open(STDERR, '>&STDOUT') or die;
}
}

my $has_bcrypt = eval { require Crypt::Bcrypt; 1 };

chomp(my $curl_path = qx{command -v curl 2>/dev/null});
Expand Down Expand Up @@ -217,6 +253,18 @@ ($hn)
die("Could not install TLS certificate for $hn") if ($ret);
}

sub is_running {
return 0 unless -f $pidfile;
open(my $fh, '<', $pidfile) or return 0;
chomp(my $pid = <$fh>); close $fh;
return ($pid && kill(0, $pid)) ? $pid : 0;
}

sub do_stop {
my $pid = is_running() or do { print "not running\n"; return; };
kill('TERM', $pid) and unlink($pidfile) and print "stopped\n";
}

# Write the example configuration file
sub write_config() {
open(my $fh, '>', 'acmeproxy.pl.conf') or die $!;
Expand Down
Loading