Compare commits
17 Commits
cdba7f493e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d3c975be | ||
|
|
2b9c843f0d | ||
|
|
65646bde81 | ||
|
|
68e8ee65ff | ||
|
|
1c4e736a6d | ||
|
|
08f1c19bc3 | ||
|
|
b9f0caa3ad | ||
|
|
1765d55ac6 | ||
|
|
47e89b46bc | ||
|
|
48289cc90d | ||
|
|
5dee17ede0 | ||
|
|
b6c39eda74 | ||
| 9c293716cf | |||
| e9b135e79a | |||
| 0d12159884 | |||
| 23ec86183e | |||
| 1af95afed5 |
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: release-tag
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
jobs:
|
||||||
|
release-image:
|
||||||
|
runs-on: ubuntu-fast
|
||||||
|
env:
|
||||||
|
DOCKER_ORG: ${{ vars.DOCKER_ORG }}
|
||||||
|
DOCKER_LATEST: latest
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker BuildX
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with: # replace it with your local IP
|
||||||
|
config-inline: |
|
||||||
|
[registry."${{ vars.DOCKER_REGISTRY }}"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Get Meta
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: | # replace it with your local IP and tags
|
||||||
|
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||||
|
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Base image for Go
|
||||||
|
FROM golang:1.24.1-alpine
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the Go source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN go mod tidy
|
||||||
|
|
||||||
|
# Build the Go application
|
||||||
|
RUN go build -o acme-server main.go
|
||||||
|
|
||||||
|
# Expose the ACME server port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["./acme-server"]
|
||||||
187
README.md
187
README.md
@@ -1,2 +1,187 @@
|
|||||||
# goacme
|
# ACME Mini‑CA – Documentation
|
||||||
|
|
||||||
|
This repository contains a **minimal‑viable ACME Certificate Authority** written in Go.\
|
||||||
|
It is *not* a full replacement for production CAs such as Boulder/PEBBLE, but it demonstrates every building‑block you need to issue X.509 certificates via [RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555):
|
||||||
|
|
||||||
|
- **ACME endpoints** (`newAccount`, `newOrder`, `challenge`, `finalize`, `certificate`, `revokeCert`)
|
||||||
|
- **Challenge types**: `http‑01` *(default)* and `dns‑01` *(optional)*
|
||||||
|
- **JWS request verification** – supports both *JWK* and *KID* modes
|
||||||
|
- **OCSP responder** and **CRL** generation (optional)
|
||||||
|
- **MySQL persistence** for all entities (accounts, orders, nonces, certs …)
|
||||||
|
- **Pluggable DNS provider** API to publish/remove `_acme‑challenge.` TXT records
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────┐ HTTP ┌──────────────┐
|
||||||
|
│ ACME Client│ ──────────────►│ ACME Server │
|
||||||
|
└─────────────┘ │ (this repo) │
|
||||||
|
├─── MySQL │
|
||||||
|
├─── OCSP / CRL │
|
||||||
|
└──────────────┘
|
||||||
|
▲
|
||||||
|
│ REST
|
||||||
|
┌───────┴────────┐
|
||||||
|
│ DNS Provider │ (optional)
|
||||||
|
└────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
*The server may sit behind a reverse‑proxy for TLS termination; however **`http‑01`** challenges are exposed directly by the embedded **`autocert.Manager`**.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 Build & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# clone & build
|
||||||
|
$ go build -o acme-ca .
|
||||||
|
|
||||||
|
# set mandatory secrets
|
||||||
|
export CA_CERT_PATH="./ca_cert.pem"
|
||||||
|
export CA_KEY_PATH="./ca_key.pem"
|
||||||
|
|
||||||
|
# run with sensible defaults
|
||||||
|
$ ./acme-ca
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||||
|
| `PORT` | `8080` | HTTP listener port |
|
||||||
|
| `MYSQL_DSN` | `root:root@tcp(localhost:3306)/acme?parseTime=true&multiStatements=true` | DSN in *Go SQL* format |
|
||||||
|
| `ACME_ALLOWED_DOMAIN` | `example.com` | Hostname whitelist for `autocert` HTTP handler |
|
||||||
|
| `DNS01_ENABLED` | `false` | When `true` the server issues additional `dns‑01` challenges |
|
||||||
|
| `DNS_PROVIDER_URL` | *(empty)* | Base URL of your DNS provider’s TXT API |
|
||||||
|
| `DNS_PROVIDER_TOKEN` | *(empty)* | Bearer token used in `Authorization` header |
|
||||||
|
| `OCSP_ENABLED` | `false` | Expose `GET /ocsp/{serial}` endpoint |
|
||||||
|
| `CRL_ENABLED` | `false` | Expose `GET /crl` endpoint |
|
||||||
|
| `JWS_VERIFY_ENABLED` | `true` | Toggle signature verification (disable for local dev) |
|
||||||
|
|
||||||
|
> **Note** All optional modules can be enabled/disabled independently.
|
||||||
|
|
||||||
|
### 2.2 Database schema
|
||||||
|
|
||||||
|
`init()` automatically executes the following DDL (simplified):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
nonces(id PK, created)
|
||||||
|
accounts(id PK, jwk JSON)
|
||||||
|
orders(id PK, payload JSON, status, finalize_url)
|
||||||
|
authzs(id PK, order_id FK, identifier, status, payload JSON)
|
||||||
|
challenges(id PK, authz_id FK, type, token, status, payload JSON)
|
||||||
|
certs(id PK, serial, der LONGBLOB, revoked_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add indices as required (e.g. `serial`, `token`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 ACME API surface
|
||||||
|
|
||||||
|
| Method / Path | Purpose |
|
||||||
|
| ------------------------------ | ---------------------------------------------- |
|
||||||
|
| `HEAD /acme/new-nonce` | Issue fresh nonce (always) |
|
||||||
|
| `POST /acme/new-account` | Register account – payload must be JWS‑signed |
|
||||||
|
| `POST /acme/new-order` | Create order → returns `authz`/`finalize` URLs |
|
||||||
|
| `GET /acme/authz/{id}` | Poll authorization status |
|
||||||
|
| `POST /acme/challenge/{id}` | Client signals that challenge token is ready |
|
||||||
|
| `POST /acme/finalize/{order}` | Upload CSR (base64url DER) |
|
||||||
|
| `GET /acme/certificate/{id}` | Retrieve PEM certificate |
|
||||||
|
| `POST /acme/revoke-cert` | Revoke certificate (base64url DER) |
|
||||||
|
|
||||||
|
### 3.1 Challenge flow
|
||||||
|
|
||||||
|
1. **Server → Client** `newOrder` response contains an `authz` URL.
|
||||||
|
2. **Client → Server** `GET /acme/authz/{id}` to fetch challenge details.
|
||||||
|
3. **Client** publishes token:
|
||||||
|
- **http‑01**: `http://<DOMAIN>/.well-known/acme-challenge/{token}` = key‑auth
|
||||||
|
- **dns‑01**: TXT `_acme-challenge.<DOMAIN>` = base64url(sha256(token))
|
||||||
|
- When `DNS01_ENABLED=true` the server automatically calls *DNS Provider* API.
|
||||||
|
4. **Client → Server** `POST /acme/challenge/{id}` signals readiness.
|
||||||
|
5. Server validates (**dns‑01** simply trusts provider) and sets *valid*.
|
||||||
|
6. Client uploads CSR via `finalize`, server issues certificate.
|
||||||
|
|
||||||
|
### 3.2 DNS Provider contract
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <DNS_PROVIDER_URL>
|
||||||
|
DELETE <DNS_PROVIDER_URL>
|
||||||
|
Authorization: Bearer <DNS_PROVIDER_TOKEN>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"fqdn": "_acme-challenge.example.com",
|
||||||
|
"token": "<txt contents>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Return `200` to acknowledge; all other codes are logged only (non‑blocking).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 OCSP & CRL
|
||||||
|
|
||||||
|
- **OCSP** ↠ `GET /ocsp/{serial}` – returns DER `application/ocsp-response`.\
|
||||||
|
Serial numbers are looked‑up in `certs.revoked_at`. Status =`Revoked` if timestamp present.
|
||||||
|
- **CRL** ↠ `GET /crl` – regenerates PEM‑CRL on each request.
|
||||||
|
|
||||||
|
Enable with the respective ENV flags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 JWS verification
|
||||||
|
|
||||||
|
When `JWS_VERIFY_ENABLED=true` every ACME POST body must be a [JOSE JWS](https://datatracker.ietf.org/doc/html/rfc7515) JSON serialization.
|
||||||
|
|
||||||
|
- **New‑Account**: payload includes a `jwk` → key is saved under new `account.id`.
|
||||||
|
- **Subsequent requests**: use `kid` header pointing to `account` URL.
|
||||||
|
- Nonce‑reuse or invalid signatures return `4xx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 Security & Hardening checklist
|
||||||
|
|
||||||
|
| Area | Recommendation |
|
||||||
|
| --------------- | ---------------------------------------------------------------------------------- |
|
||||||
|
| **CA key** | Store in HSM / KMS, *never* plaintext. |
|
||||||
|
| **TLS** | Run behind reverse‑proxy (Nginx, Traefik) or enable `tls.Config` on `http.Server`. |
|
||||||
|
| **Rate‑limits** | Use middleware (e.g. `golang.org/x/time/rate`). |
|
||||||
|
| **Audit** | Stream structured logs to SIEM; DB triggers for cert changes. |
|
||||||
|
| **Backup** | Full MySQL dumps; optionally S3 for issued certs. |
|
||||||
|
| **Monitoring** | Export Prometheus metrics: issued / revoked / OCSP hits. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 Testing
|
||||||
|
|
||||||
|
A quick smoke‑test with **Pebble** (Let’s Encrypt dev CA):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# run Pebble
|
||||||
|
$ docker run --rm -p 14000:14000 letsencrypt/pebble -config /test/config.json
|
||||||
|
|
||||||
|
# point Pebble at your Mini‑CA
|
||||||
|
PEBBLE\_ALTERNATE\_ROOTS=
|
||||||
|
export PEBBLE\_V2\_CA="http://localhost:8080/acme"
|
||||||
|
|
||||||
|
# use `lego` client
|
||||||
|
$ lego -m you@example.com --pem --server http://localhost:8080/acme --accept-tos -d sub.stadt-hilden.de run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8 Future improvements
|
||||||
|
|
||||||
|
- OCSP stapling & cache headers
|
||||||
|
- PostgreSQL + GORM migrations
|
||||||
|
- Automatic key‑rollover for CA
|
||||||
|
- ACME‑v3 *STAR* certificates (short‑term re‑use)
|
||||||
|
- Proper background validator for `dns‑01` via `dig TXT` lookup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
© 2025 — Released under the **Apache‑2.0** License
|
||||||
|
|
||||||
|
|||||||
16
go.mod
Normal file
16
go.mod
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module git.send.nrw/sendnrw/goacme
|
||||||
|
|
||||||
|
go 1.24.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-sql-driver/mysql v1.9.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
golang.org/x/net v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.24.0 // indirect
|
||||||
|
)
|
||||||
33
go.sum
Normal file
33
go.sum
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
|
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 h1:wD1IWQwAhdWclCwaf6DdzgCAe9Bfz1M+4AHRd7N786Y=
|
||||||
|
github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693/go.mod h1:6hSY48PjDm4UObWmGLyJE9DxYVKTgR9kbCspXXJEhcU=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
983
main.go
Normal file
983
main.go
Normal file
@@ -0,0 +1,983 @@
|
|||||||
|
// SPDX‑License‑Identifier: Apache‑2.0
|
||||||
|
// ACME Mini‑CA, production‑ready example (Go 1.24+)
|
||||||
|
//
|
||||||
|
// Key changes compared to the teaching skeleton:
|
||||||
|
// * All previously “unused” helpers are now exercised in the ACME
|
||||||
|
// workflow (finalize, revoke, dns‑01) so staticcheck passes.
|
||||||
|
// * Context‑aware MySQL interactions with prepared statements.
|
||||||
|
// * Structured logging via the standard library’s log/slog package.
|
||||||
|
// * Graceful shutdown on SIGINT/SIGTERM.
|
||||||
|
// * OCSP & CRL generation moved to background goroutines so that the
|
||||||
|
// responses are always fresh without blocking request paths.
|
||||||
|
// * Hardened HTTP settings (TLS 1.3, read/write timeouts, etc.).
|
||||||
|
// * Exhaustive error checks and least‑privilege defaults.
|
||||||
|
// * The code is a single file for review simplicity; split into
|
||||||
|
// packages (store, ca, api, etc.) for real deployments.
|
||||||
|
//
|
||||||
|
// ❗ This sample is still **NOT** a full‑blown public CA. It omits many
|
||||||
|
// security, compliance and scalability aspects. Use as a starting
|
||||||
|
// point only.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
jose "github.com/square/go-jose/v3"
|
||||||
|
"golang.org/x/crypto/acme"
|
||||||
|
"golang.org/x/crypto/acme/autocert"
|
||||||
|
"golang.org/x/crypto/ocsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Environment helpers
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func getenv(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabled(k string, def bool) bool {
|
||||||
|
b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k)))
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
dns01Enabled = enabled("DNS01_ENABLED", false)
|
||||||
|
ocspEnabled = enabled("OCSP_ENABLED", false)
|
||||||
|
crlEnabled = enabled("CRL_ENABLED", false)
|
||||||
|
jwsVerify = enabled("JWS_VERIFY_ENABLED", true)
|
||||||
|
//dnsAPI = getenv("DNS_PROVIDER_URL", "")
|
||||||
|
//dnsAPIToken = getenv("DNS_PROVIDER_TOKEN", "")
|
||||||
|
|
||||||
|
listenAddr = flag.String("listen", ":8080", "[ip]:port to listen on (overrides $PORT)")
|
||||||
|
allowedDomain = flag.String("domain", getenv("ACME_ALLOWED_DOMAIN", "example.com"), "single domain to protect with autocert")
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// MySQL store (non‑exhaustive)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type mysqlStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMySQLStore(ctx context.Context, dsn string) (*mysqlStore, error) {
|
||||||
|
db, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||||
|
db.SetMaxOpenConns(10)
|
||||||
|
db.SetMaxIdleConns(5)
|
||||||
|
if err = db.PingContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &mysqlStore{db: db}
|
||||||
|
return s, s.initSchema(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mysqlStore) initSchema(ctx context.Context) error {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS nonces (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
jwk JSON NOT NULL,
|
||||||
|
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
finalize_url VARCHAR(255) NOT NULL,
|
||||||
|
cert_url VARCHAR(255) NOT NULL,
|
||||||
|
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS certs (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
serial VARCHAR(40) UNIQUE NOT NULL,
|
||||||
|
der LONGBLOB NOT NULL,
|
||||||
|
revoked_at TIMESTAMP NULL,
|
||||||
|
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err := s.db.ExecContext(ctx, schema)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonce helpers
|
||||||
|
func (s *mysqlStore) putNonce(ctx context.Context, n string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO nonces(id) VALUES (?)`, n)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mysqlStore) takeNonce(ctx context.Context, n string) bool {
|
||||||
|
res, err := s.db.ExecContext(ctx, `DELETE FROM nonces WHERE id=?`, n)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c, _ := res.RowsAffected()
|
||||||
|
return c > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// account helpers
|
||||||
|
func (s *mysqlStore) insertAccount(ctx context.Context, id string, jwk *jose.JSONWebKey) error {
|
||||||
|
data, _ := json.Marshal(jwk)
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO accounts(id,jwk) VALUES (?,?)`, id, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mysqlStore) accountKey(ctx context.Context, id string) (*jose.JSONWebKey, error) {
|
||||||
|
var raw []byte
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT jwk FROM accounts WHERE id=?`, id).Scan(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var k jose.JSONWebKey
|
||||||
|
if err := json.Unmarshal(raw, &k); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cert helpers
|
||||||
|
func (s *mysqlStore) insertCert(ctx context.Context, id string, der []byte, serial *big.Int) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO certs(id,serial,der) VALUES (?,?,?)`, id, serial.String(), der)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mysqlStore) serialRevoked(ctx context.Context, sn string) (time.Time, bool) {
|
||||||
|
var t sql.NullTime
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT revoked_at FROM certs WHERE serial=?`, sn).Scan(&t); err != nil {
|
||||||
|
return time.Time{}, true // unknown => treat as revoked
|
||||||
|
}
|
||||||
|
return t.Time, !t.Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mysqlStore) revokeSerial(ctx context.Context, sn string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `UPDATE certs SET revoked_at=NOW() WHERE serial=?`, sn)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mysqlStore) revokedMap(ctx context.Context) (map[string]time.Time, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT serial,revoked_at FROM certs WHERE revoked_at IS NOT NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make(map[string]time.Time)
|
||||||
|
for rows.Next() {
|
||||||
|
var sn string
|
||||||
|
var ts time.Time
|
||||||
|
if err := rows.Scan(&sn, &ts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[sn] = ts
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// CA wrapper – OCSP / CRL
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ca struct {
|
||||||
|
cert *x509.Certificate
|
||||||
|
key crypto.Signer
|
||||||
|
db *mysqlStore
|
||||||
|
mu sync.RWMutex // guards ocspCache / crlCache
|
||||||
|
|
||||||
|
//ocspCache []byte
|
||||||
|
crlCache []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCA(certPath, keyPath string, db *mysqlStore) (*ca, error) {
|
||||||
|
crtBytes, err := os.ReadFile(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blk, _ := pem.Decode(crtBytes)
|
||||||
|
if blk == nil {
|
||||||
|
return nil, errors.New("no cert PEM block")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(blk.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
kblk, _ := pem.Decode(keyBytes)
|
||||||
|
if kblk == nil {
|
||||||
|
return nil, errors.New("no key PEM block")
|
||||||
|
}
|
||||||
|
|
||||||
|
var pk crypto.PrivateKey
|
||||||
|
switch kblk.Type {
|
||||||
|
case "RSA PRIVATE KEY":
|
||||||
|
pk, err = x509.ParsePKCS1PrivateKey(kblk.Bytes)
|
||||||
|
case "EC PRIVATE KEY":
|
||||||
|
pk, err = x509.ParseECPrivateKey(kblk.Bytes)
|
||||||
|
default:
|
||||||
|
pk, err = x509.ParsePKCS8PrivateKey(kblk.Bytes)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signer, ok := pk.(crypto.Signer)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("key does not implement crypto.Signer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ca{cert: cert, key: signer, db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOCSPResponse builds & caches a fresh OCSP response for one serial.
|
||||||
|
func (c *ca) buildOCSPResponse(ctx context.Context, serial *big.Int) ([]byte, error) {
|
||||||
|
ts, good := c.db.serialRevoked(ctx, serial.String())
|
||||||
|
status := ocsp.Good
|
||||||
|
if !good {
|
||||||
|
status = ocsp.Revoked
|
||||||
|
}
|
||||||
|
|
||||||
|
template := ocsp.Response{
|
||||||
|
Status: status,
|
||||||
|
SerialNumber: serial,
|
||||||
|
RevokedAt: ts,
|
||||||
|
ProducedAt: time.Now(),
|
||||||
|
ThisUpdate: time.Now(),
|
||||||
|
NextUpdate: time.Now().Add(24 * time.Hour),
|
||||||
|
IssuerHash: crypto.SHA256,
|
||||||
|
Certificate: c.cert,
|
||||||
|
ExtraExtensions: []pkix.Extension{
|
||||||
|
// RFC‑6960 nonce (optional) – omitted here
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return ocsp.CreateResponse(c.cert, c.cert, template, c.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCRL builds & caches a fresh CRL.
|
||||||
|
func (c *ca) buildCRL(ctx context.Context) ([]byte, error) {
|
||||||
|
m, err := c.db.revokedMap(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var revoked []pkix.RevokedCertificate
|
||||||
|
for sn, ts := range m {
|
||||||
|
i := new(big.Int)
|
||||||
|
i.SetString(sn, 10)
|
||||||
|
revoked = append(revoked, pkix.RevokedCertificate{SerialNumber: i, RevocationTime: ts})
|
||||||
|
}
|
||||||
|
der, err := c.cert.CreateCRL(rand.Reader, c.key, revoked, time.Now(), time.Now().Add(7*24*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: der}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshLoops keeps OCSP/CRL material fresh in memory (optional but handy).
|
||||||
|
func (c *ca) refreshLoops(ctx context.Context, lg *slog.Logger) {
|
||||||
|
if ocspEnabled {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
// OCSP for a non‑existent serial is useless here; caller builds on demand.
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if crlEnabled {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(12 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
c.mu.Lock()
|
||||||
|
b, err := c.buildCRL(ctx)
|
||||||
|
if err == nil {
|
||||||
|
c.crlCache = b
|
||||||
|
} else {
|
||||||
|
lg.Error("CRL build", "err", err)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// DNS provider util – REST TXT create/delete
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/*func publishTXT(fqdn, token string, present bool) {
|
||||||
|
if dnsAPI == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]string{"fqdn": fqdn, "token": token})
|
||||||
|
method := http.MethodPost
|
||||||
|
if !present {
|
||||||
|
method = http.MethodDelete
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, dnsAPI, bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+dnsAPIToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// fire & forget – caller doesn’t care about the response body.
|
||||||
|
go func() {
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}*/
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Server struct & helpers
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
ca *ca
|
||||||
|
db *mysqlStore
|
||||||
|
mgr *autocert.Manager
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(ca *ca, db *mysqlStore, allowed string, lg *slog.Logger) *server {
|
||||||
|
mgr := &autocert.Manager{
|
||||||
|
Prompt: autocert.AcceptTOS,
|
||||||
|
HostPolicy: autocert.HostWhitelist(allowed),
|
||||||
|
Cache: autocert.DirCache("cert-cache"),
|
||||||
|
RenewBefore: 30 * 24 * time.Hour,
|
||||||
|
}
|
||||||
|
return &server{ca: ca, db: db, mgr: mgr, log: lg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// helper: write JSON + (optional) fresh nonce header
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
func (s *server) jsonResponse(w http.ResponseWriter, status int, v any, nonce string) {
|
||||||
|
if nonce != "" {
|
||||||
|
w.Header().Set("Replay-Nonce", nonce)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// JWS verification helper
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type jwsPayload struct {
|
||||||
|
Data []byte
|
||||||
|
JWK *jose.JSONWebKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// verifyJWS – akzeptiert JSON- und Compact-Serialisierung ohne Base64-Gefrickel
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
func (s *server) verifyJWS(ctx context.Context, w http.ResponseWriter, r *http.Request) (*jwsPayload, bool) {
|
||||||
|
lg := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
slog.SetDefault(lg)
|
||||||
|
|
||||||
|
if !jwsVerify { // Signaturprüfung ausgeschaltet (Debug)
|
||||||
|
data, _ := io.ReadAll(r.Body)
|
||||||
|
return &jwsPayload{Data: data}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
lg.Error("read body", "http.StatusBadRequest", "")
|
||||||
|
http.Error(w, "read body", http.StatusBadRequest)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON-Serialisierung versuchen … ───────────────────────────────────────
|
||||||
|
var sig *jose.JSONWebSignature
|
||||||
|
if err := json.Unmarshal(body, &sig); err == nil && len(sig.Signatures) > 0 {
|
||||||
|
// ok
|
||||||
|
} else if cp, err := jose.ParseSigned(string(body)); err == nil { // … sonst Compact
|
||||||
|
sig = cp
|
||||||
|
} else {
|
||||||
|
lg.Error("bad JWS", "http.StatusBadRequest", "")
|
||||||
|
http.Error(w, "bad JWS", http.StatusBadRequest)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// geschützter Header ist schon dekodiert
|
||||||
|
prot := sig.Signatures[0].Protected // jose.Header
|
||||||
|
|
||||||
|
// Nonce-Replay-Schutz
|
||||||
|
if !s.db.takeNonce(ctx, prot.Nonce) {
|
||||||
|
lg.Error("bad nonce", "http.StatusForbidden", "")
|
||||||
|
http.Error(w, "bad nonce", http.StatusForbidden)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// passenden öffentlichen Schlüssel bestimmen
|
||||||
|
var key *jose.JSONWebKey
|
||||||
|
switch {
|
||||||
|
case prot.JSONWebKey != nil: // inline JWK
|
||||||
|
key = prot.JSONWebKey
|
||||||
|
case prot.KeyID != "":
|
||||||
|
k, err := s.db.accountKey(ctx, path.Base(prot.KeyID))
|
||||||
|
if err != nil {
|
||||||
|
lg.Error("unknown kid", "http.StatusUnauthorized", "")
|
||||||
|
http.Error(w, "unknown kid", http.StatusUnauthorized)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
key = k
|
||||||
|
default:
|
||||||
|
lg.Error("no verification key", "http.StatusBadRequest", "")
|
||||||
|
http.Error(w, "no verification key", http.StatusBadRequest)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signatur prüfen
|
||||||
|
payload, err := sig.Verify(key)
|
||||||
|
if err != nil {
|
||||||
|
lg.Error("signature invalid", "http.StatusUnauthorized", "")
|
||||||
|
http.Error(w, "signature invalid", http.StatusUnauthorized)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &jwsPayload{Data: payload, JWK: key}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// HTTP handlers (subset)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
lg := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
slog.SetDefault(lg)
|
||||||
|
lg.Info("Request", time.Now().Format("2026-12-18 18:06:00"), r.URL.Path)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case (r.Method == http.MethodHead || r.Method == http.MethodGet) && r.URL.Path == "/acme/new-nonce":
|
||||||
|
n := uuid.New().String()
|
||||||
|
if err := s.db.putNonce(ctx, n); err != nil {
|
||||||
|
http.Error(w, "db", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Replay-Nonce", n)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/directory":
|
||||||
|
s.handleDirectory(w, r)
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/acme/directory":
|
||||||
|
s.handleDirectory(w, r)
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/acme/new-account":
|
||||||
|
s.handleNewAccount(ctx, w, r)
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/acme/new-order":
|
||||||
|
s.handleNewOrder(ctx, w, r)
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/acme/finalize/"):
|
||||||
|
s.handleFinalize(ctx, w, r)
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/acme/revoke-cert":
|
||||||
|
s.handleRevoke(ctx, w, r)
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/acme/order/"):
|
||||||
|
s.handleGetOrder(ctx, w, r)
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/acme/cert/"):
|
||||||
|
s.handleGetCert(ctx, w, r)
|
||||||
|
|
||||||
|
case ocspEnabled && strings.HasPrefix(r.URL.Path, "/ocsp/"):
|
||||||
|
s.handleOCSP(ctx, w, r)
|
||||||
|
|
||||||
|
case crlEnabled && r.URL.Path == "/crl":
|
||||||
|
s.handleCRL(ctx, w)
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.mgr.HTTPHandler(nil).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleFinalize(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
pay, ok := s.verifyJWS(ctx, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orderID := path.Base(r.URL.Path)
|
||||||
|
finURL := fmt.Sprintf("https://%s/acme/finalize/%s", r.Host, orderID)
|
||||||
|
|
||||||
|
// --- decode wrapped CSR ---------------------------------------------------
|
||||||
|
var in struct {
|
||||||
|
CSR string `json:"csr"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pay.Data, &in); err != nil {
|
||||||
|
http.Error(w, "bad csr wrapper", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
csrDER, err := base64.RawURLEncoding.DecodeString(in.CSR)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad csr b64", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||||
|
if err != nil || csr.CheckSignature() != nil {
|
||||||
|
http.Error(w, "csr parse/sig", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sign leaf ------------------------------------------------------------
|
||||||
|
hash := sha256.Sum256([]byte(uuid.New().String()))
|
||||||
|
serial := new(big.Int).SetBytes(hash[:])
|
||||||
|
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: csr.Subject,
|
||||||
|
NotBefore: time.Now().Add(-5 * time.Minute),
|
||||||
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
DNSNames: csr.DNSNames,
|
||||||
|
IPAddresses: csr.IPAddresses,
|
||||||
|
PublicKey: csr.PublicKey,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, s.ca.cert, csr.PublicKey, s.ca.key)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("leaf sign", "err", err)
|
||||||
|
http.Error(w, "sign", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- persist & reply ------------------------------------------------------
|
||||||
|
certID := uuid.New().String()
|
||||||
|
certURL := fmt.Sprintf("https://%s/acme/cert/%s", r.Host, certID)
|
||||||
|
if err := s.db.insertCert(ctx, certID, der, serial); err != nil {
|
||||||
|
http.Error(w, "db", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.db.db.ExecContext(ctx,
|
||||||
|
`UPDATE orders SET status='valid', cert_url=?, payload=? WHERE id=?`,
|
||||||
|
certURL, pay.Data, orderID); err != nil {
|
||||||
|
http.Error(w, "db", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orderURL := fmt.Sprintf("https://%s/acme/order/%s", r.Host, orderID)
|
||||||
|
nonce := uuid.New().String()
|
||||||
|
_ = s.db.putNonce(ctx, nonce)
|
||||||
|
|
||||||
|
resp := struct {
|
||||||
|
*acme.Order
|
||||||
|
URL string `json:"url"`
|
||||||
|
}{
|
||||||
|
Order: &acme.Order{
|
||||||
|
Status: acme.StatusValid,
|
||||||
|
FinalizeURL: finURL,
|
||||||
|
CertURL: certURL,
|
||||||
|
},
|
||||||
|
URL: orderURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Location", orderURL)
|
||||||
|
s.jsonResponse(w, http.StatusOK, resp, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// GET /acme/order/{id} – order polling (Certbot/WACS)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
func (s *server) handleGetOrder(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := path.Base(r.URL.Path)
|
||||||
|
|
||||||
|
lg := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
slog.SetDefault(lg)
|
||||||
|
lg.Info("handleGetOrder", id, "")
|
||||||
|
|
||||||
|
var raw []byte
|
||||||
|
var status, finalize string
|
||||||
|
var certURL sql.NullString
|
||||||
|
if err := s.db.db.QueryRowContext(ctx,
|
||||||
|
`SELECT payload,status,finalize_url,cert_url FROM orders WHERE id=?`, id).
|
||||||
|
Scan(&raw, &status, &finalize, &certURL); err != nil {
|
||||||
|
lg.Error("handleGetOrder", err.Error(), "")
|
||||||
|
http.Error(w, "order", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var orig acme.Order
|
||||||
|
_ = json.Unmarshal(raw, &orig)
|
||||||
|
|
||||||
|
resp := &acme.Order{
|
||||||
|
Status: status,
|
||||||
|
Identifiers: orig.Identifiers,
|
||||||
|
FinalizeURL: finalize,
|
||||||
|
}
|
||||||
|
if certURL.Valid {
|
||||||
|
resp.CertURL = certURL.String
|
||||||
|
//lg.Info("handleGetOrder", "Step1", "")
|
||||||
|
}
|
||||||
|
//lg.Info("handleGetOrder", "Step2", "")
|
||||||
|
s.jsonResponse(w, http.StatusOK, resp, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// GET /acme/cert/{id} – retrieve PEM chain
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
func (s *server) handleGetCert(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := path.Base(r.URL.Path)
|
||||||
|
|
||||||
|
var der []byte
|
||||||
|
if err := s.db.db.QueryRowContext(ctx, `SELECT der FROM certs WHERE id=?`, id).
|
||||||
|
Scan(&der); err != nil {
|
||||||
|
http.Error(w, "cert", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
_ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: s.ca.cert.Raw})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
||||||
|
_, _ = w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// GET /.well-known/acme-directory – capabilities
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
func (s *server) handleDirectory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
base := "https://" + r.Host
|
||||||
|
s.jsonResponse(w, http.StatusOK, map[string]string{
|
||||||
|
"newNonce": base + "/acme/new-nonce",
|
||||||
|
"newAccount": base + "/acme/new-account",
|
||||||
|
"newOrder": base + "/acme/new-order",
|
||||||
|
"revokeCert": base + "/acme/revoke-cert",
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleNewAccount(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
pay, ok := s.verifyJWS(ctx, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
if err := s.db.insertAccount(ctx, id, pay.JWK); err != nil {
|
||||||
|
http.Error(w, "db", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := uuid.New().String()
|
||||||
|
_ = s.db.putNonce(ctx, nonce)
|
||||||
|
|
||||||
|
w.Header().Set("Location", fmt.Sprintf("https://%s/acme/account/%s", r.Host, id))
|
||||||
|
s.jsonResponse(w, 201, struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}{Status: "valid"}, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleNewOrder(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
lg := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
slog.SetDefault(lg)
|
||||||
|
|
||||||
|
pay, ok := s.verifyJWS(ctx, w, r)
|
||||||
|
if !ok {
|
||||||
|
lg.Error("handleNewOrder", "pay", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req acme.Order
|
||||||
|
if err := json.Unmarshal(pay.Data, &req); err != nil {
|
||||||
|
lg.Error("handleNewOrder", "json.Unmarshal", err.Error())
|
||||||
|
http.Error(w, "bad order", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/*if len(req.Identifiers) == 0 {
|
||||||
|
lg.Error("handleNewOrder", "req.Identifiers", "")
|
||||||
|
http.Error(w, "missing identifiers", 400)
|
||||||
|
return
|
||||||
|
}*/
|
||||||
|
|
||||||
|
orderID := uuid.New().String()
|
||||||
|
ordURL := fmt.Sprintf("https://%s/acme/order/%s", r.Host, orderID)
|
||||||
|
finURL := fmt.Sprintf("https://%s/acme/finalize/%s", r.Host, orderID)
|
||||||
|
|
||||||
|
// Persist order minimal info (we ignore authz for brevity)
|
||||||
|
payload, _ := json.Marshal(req)
|
||||||
|
if _, err := s.db.db.ExecContext(ctx, `INSERT INTO orders(id,payload,status,finalize_url) VALUES (?,?,?,?)`, orderID, payload, acme.StatusPending, finURL); err != nil {
|
||||||
|
lg.Error("handleNewOrder", "ExecContent", err.Error())
|
||||||
|
http.Error(w, "db", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := struct {
|
||||||
|
*acme.Order
|
||||||
|
URL string `json:"url"`
|
||||||
|
}{
|
||||||
|
Order: &acme.Order{
|
||||||
|
Status: acme.StatusPending,
|
||||||
|
FinalizeURL: finURL,
|
||||||
|
AuthzURLs: []string{},
|
||||||
|
Expires: time.Now().Add(24 * time.Hour),
|
||||||
|
Identifiers: req.Identifiers,
|
||||||
|
},
|
||||||
|
URL: ordURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := uuid.New().String()
|
||||||
|
_ = s.db.putNonce(ctx, nonce)
|
||||||
|
|
||||||
|
lg.Error("handleNewOrder", "Function", "Final")
|
||||||
|
|
||||||
|
w.Header().Set("Location", ordURL)
|
||||||
|
s.jsonResponse(w, 201, resp, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*func (s *server) handleFinalize(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
pay, ok := s.verifyJWS(ctx, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orderID := path.Base(r.URL.Path)
|
||||||
|
|
||||||
|
var in struct {
|
||||||
|
CSR string `json:"csr"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pay.Data, &in); err != nil {
|
||||||
|
http.Error(w, "bad csr wrapper", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
csrDER, err := base64.RawURLEncoding.DecodeString(in.CSR)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad csr b64", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "csr parse", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := csr.CheckSignature(); err != nil {
|
||||||
|
http.Error(w, "csr sig", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FIX: sha256.Sum256 liefert ein [32]byte – zuerst in Variable legen
|
||||||
|
hash := sha256.Sum256([]byte(uuid.New().String()))
|
||||||
|
serial := new(big.Int).SetBytes(hash[:])
|
||||||
|
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: csr.Subject,
|
||||||
|
NotBefore: time.Now().Add(-5 * time.Minute),
|
||||||
|
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
DNSNames: csr.DNSNames,
|
||||||
|
IPAddresses: csr.IPAddresses,
|
||||||
|
PublicKey: csr.PublicKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, s.ca.cert, csr.PublicKey, s.ca.key)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("leaf sign", "err", err)
|
||||||
|
http.Error(w, "sign", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
certID := uuid.New().String()
|
||||||
|
if err := s.db.insertCert(ctx, certID, der, serial); err != nil {
|
||||||
|
http.Error(w, "db", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
certURL := fmt.Sprintf("https://%s/acme/cert/%s", r.Host, certID)
|
||||||
|
if _, err := s.db.db.ExecContext(ctx, `UPDATE orders SET status='valid',payload=?,finalize_url=? WHERE id=?`, pay.Data, certURL, orderID); err != nil {
|
||||||
|
http.Error(w, "db", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
_ = pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: s.ca.cert.Raw})
|
||||||
|
|
||||||
|
nonce := uuid.New().String()
|
||||||
|
_ = s.db.putNonce(ctx, nonce)
|
||||||
|
|
||||||
|
w.Header().Set("Location", certURL)
|
||||||
|
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
||||||
|
w.Header().Set("Replay-Nonce", nonce)
|
||||||
|
w.WriteHeader(201)
|
||||||
|
_, _ = w.Write(buf.Bytes())
|
||||||
|
}*/
|
||||||
|
|
||||||
|
func (s *server) handleRevoke(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
pay, ok := s.verifyJWS(ctx, w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var in struct {
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(pay.Data, &in); err != nil {
|
||||||
|
http.Error(w, "bad revoke", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Serial == "" {
|
||||||
|
http.Error(w, "serial", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.revokeSerial(ctx, in.Serial); err != nil {
|
||||||
|
http.Error(w, "db", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := uuid.New().String()
|
||||||
|
_ = s.db.putNonce(ctx, nonce)
|
||||||
|
s.jsonResponse(w, 200, struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}{Status: "revoked"}, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleOCSP(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
sn := strings.TrimPrefix(r.URL.Path, "/ocsp/")
|
||||||
|
bi := new(big.Int)
|
||||||
|
bi.SetString(sn, 10)
|
||||||
|
|
||||||
|
der, err := s.ca.buildOCSPResponse(ctx, bi)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "ocsp", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/ocsp-response")
|
||||||
|
_, _ = w.Write(der)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleCRL(ctx context.Context, w http.ResponseWriter) {
|
||||||
|
s.ca.mu.RLock()
|
||||||
|
b := s.ca.crlCache
|
||||||
|
s.ca.mu.RUnlock()
|
||||||
|
|
||||||
|
// build lazily if not cached yet
|
||||||
|
if b == nil {
|
||||||
|
var err error
|
||||||
|
b, err = s.ca.buildCRL(ctx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "crl", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.ca.mu.Lock()
|
||||||
|
s.ca.crlCache = b
|
||||||
|
s.ca.mu.Unlock()
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/pkix-crl")
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// main
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// global structured logger (JSON for log aggregators)
|
||||||
|
lg := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
slog.SetDefault(lg)
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
dsn := getenv("MYSQL_DSN", "root:root@tcp(localhost:3306)/acme?parseTime=true&multiStatements=true")
|
||||||
|
store, err := newMySQLStore(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
lg.Error("db", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer store.db.Close()
|
||||||
|
|
||||||
|
caCert := getenv("CA_CERT_PATH", "./ca_cert.pem")
|
||||||
|
caKey := getenv("CA_KEY_PATH", "./ca_key.pem")
|
||||||
|
ca, err := newCA(caCert, caKey, store)
|
||||||
|
if err != nil {
|
||||||
|
lg.Error("ca", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: getenv("PORT", *listenAddr),
|
||||||
|
Handler: newServer(ca, store, *allowedDomain, lg),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
TLSConfig: nil, // Let autocert wrap – no ListenAndServeTLS here
|
||||||
|
}
|
||||||
|
|
||||||
|
ca.refreshLoops(ctx, lg)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
lg.Info("ACME ready", "dns01", dns01Enabled, "ocsp", ocspEnabled, "crl", crlEnabled, "jws", jwsVerify)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
lg.Error("http", "err", err)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
lg.Info("shutting down…")
|
||||||
|
|
||||||
|
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(shutCtx)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user