Merge feature/revival-modernization into develop

Revive the project: modern Go toolchain, Caddy provider abstraction
(Caddyfile + admin API modes), configurable local/OIDC auth, a rewritten
Vite/React/shadcn frontend, Docker image and CI pipeline.
This commit is contained in:
Pacerino
2026-06-14 02:37:44 +02:00
74 changed files with 7022 additions and 38205 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
**/node_modules
frontend/dist
frontend/.vite
backend/embed/assets
**/*.db
.git
.github
*.md
.weave
.vscode

118
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: CI
on:
push:
branches: [master]
tags: ["v*"]
pull_request:
branches: [master]
permissions:
contents: read
packages: write
# Cancel superseded runs for the same ref. Useful on a single self-hosted
# runner so a new push doesn't queue behind an outdated run.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
test-backend:
name: Test backend
runs-on: self-hosted
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
# The backend embeds frontend assets; provide a placeholder so it builds.
- name: Create embed placeholder
run: mkdir -p embed/assets && echo "<!doctype html>" > embed/assets/index.html
- name: Vet
run: go vet ./...
- name: Test
run: go test -race -count=1 ./...
test-frontend:
name: Build frontend
runs-on: self-hosted
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install
run: npm ci || npm install
- name: Type-check and build
run: |
mkdir -p ../backend/embed/assets
npm run build
docker:
name: Build & publish image
runs-on: self-hosted
needs: [test-backend, test-frontend]
# Only push on master pushes and version tags, not on PRs.
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
COMMIT=${{ github.sha }}
# Local cache persists on the self-hosted runner's disk between runs.
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
# Replace the old cache with the new one to keep it from growing
# unbounded across runs.
- name: Rotate build cache
if: always()
run: |
rm -rf /tmp/.buildx-cache
if [ -d /tmp/.buildx-cache-new ]; then
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
fi

26
.gitignore vendored
View File

@@ -5,21 +5,39 @@ frontend/node_modules
frontend/.pnp
frontend/.pnp.js
# frontend build / cache
frontend/dist
frontend/.vite
frontend/*.tsbuildinfo
# testing
frontend/coverage
# production
frontend/build
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# secrets / runtime data (never commit)
*.pem
*.key
*.db
*.sqlite
*.sqlite3
jwtkey.pem
# agent / tooling session state
.weave
.weave/
frontend/npm-debug.log*
frontend/yarn-debug.log*
frontend/yarn-error.log*
# embedded frontend build output (generated by `npm run build`)
backend/embed/assets
.vscode
.vscode

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1
# ---- Frontend build ----
FROM node:22-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci || npm install
COPY frontend/ ./
# Vite is configured (vite.config.ts) to emit into ../backend/embed/assets,
# so create that target and build into it.
RUN mkdir -p /app/backend/embed/assets && npm run build
# ---- Backend build ----
FROM golang:1.25-alpine AS backend
# CGO is required by the sqlite driver (mattn/go-sqlite3).
RUN apk add --no-cache build-base
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
# Bring in the embedded frontend assets produced in the previous stage so the
# `go:embed all:assets/**` directive has files to embed.
COPY --from=frontend /app/backend/embed/assets ./embed/assets
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=1 GOOS=linux go build \
-ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
-o /out/cpm ./cmd/main.go
# ---- Runtime ----
FROM alpine:3.21
RUN apk add --no-cache ca-certificates caddy
COPY --from=backend /out/cpm /usr/local/bin/cpm
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Sensible container defaults: keep data in /data, talk to Caddy over the API.
ENV CPM_DATAFOLDER=/data \
CPM_LOGFOLDER=/data/logs \
CPM_CADDYFILE=/data/Caddyfile \
CPM_CADDY_MODE=api \
CPM_CADDY_ADMINURL=http://localhost:2019
VOLUME ["/data"]
EXPOSE 3001 80 443
# The entrypoint launches Caddy (admin API) then CPM. Override the CMD or set
# CADDY_CONFIG to bring your own Caddy configuration.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -15,24 +15,94 @@ Caddy is installed normally on the system and integrates further Caddyfiles via
## Current features
- Adding hosts with multiple domains/upstreams
- Delete hosts
- Adding, editing and deleting hosts with multiple domains/upstreams
- Two ways to apply config to Caddy, selectable at runtime:
- **Caddyfile mode** (`CPM_CADDY_MODE=caddyfile`, default): renders a per-host
Caddyfile snippet and triggers a reload.
- **API mode** (`CPM_CADDY_MODE=api`): manages Caddy's JSON config through the
admin API, maintaining one route per host (no reload needed).
- Configurable authentication: local email/password or external OIDC/OAuth
(`CPM_AUTH_MODE=local|oidc`) with JIT user provisioning.
- Modern SPA frontend (Vite + React + TypeScript + Tailwind + shadcn/ui),
embedded in the binary by default or served from an external directory.
## Configuration
All options are environment variables prefixed with `CPM_`:
| Variable | Default | Description |
| --- | --- | --- |
| `CPM_CADDY_MODE` | `caddyfile` | `caddyfile` or `api` |
| `CPM_CADDY_ADMINURL` | `http://localhost:2019` | Caddy admin API base URL (api mode + api reload) |
| `CPM_CADDY_SERVERNAME` | `srv0` | http server name routes are managed under (api mode) |
| `CPM_CADDY_LISTEN` | `:80,:443` | listen addresses for the bootstrapped server (api mode); use e.g. `:8080` for local non-root testing |
| `CPM_CADDY_RELOADSTRATEGY` | `systemd` | `systemd`, `exec`, `api` or `none` (caddyfile mode) |
| `CPM_CADDY_BINARY` | `caddy` | caddy executable for the `exec` reload strategy |
| `CPM_CADDY_SERVICE` | `caddy.service` | systemd unit for the `systemd` reload strategy |
| `CPM_DATAFOLDER` | `/etc/caddy/` | data + per-host config folder |
| `CPM_LOGFOLDER` | `/var/log/caddy` | per-host log folder |
| `CPM_CADDYFILE` | `/etc/caddy/Caddyfile` | main Caddyfile (used by `exec`/`api` reload) |
| `CPM_FRONTENDDIR` | _(empty)_ | serve the frontend from this directory instead of the embedded assets |
| `CPM_AUTH_MODE` | `local` | `local` or `oidc` |
| `CPM_AUTH_OIDC_ISSUER` | _(empty)_ | OIDC issuer URL (e.g. Keycloak/Authelia) |
| `CPM_AUTH_OIDC_CLIENTID` | _(empty)_ | OIDC client id |
| `CPM_AUTH_OIDC_CLIENTSECRET` | _(empty)_ | OIDC client secret |
| `CPM_AUTH_OIDC_REDIRECTURL` | _(empty)_ | callback URL, e.g. `https://cpm.example.com/api/auth/oidc/callback` |
| `CPM_AUTH_OIDC_SCOPES` | `openid,profile,email` | requested scopes |
| `CPM_AUTH_OIDC_ALLOWEDDOMAINS` | _(empty)_ | optional email-domain allowlist for JIT provisioning |
## Planned features
- Login with third-party Services like Authelia, Keycloak etc.
- Editing hosts
- Logview
- Manage Plugins
## FAQ
> Why don't you use the API from Caddy itself?
> Can I use the Caddy admin API instead of writing a Caddyfile?
The API of Caddy is not documented and quite complicated for a simple web interface. Many features like HSTS, HTTP/2, SSL etc. are already included in Caddy and don't need to be specially configured.
Yes. Set `CPM_CADDY_MODE=api` and CPM will manage Caddy's JSON config through the
admin API, keeping one route per host. The Caddyfile mode remains the default and
is the simplest setup, since features like HSTS, HTTP/2, SSL etc. come for free.
> Can I use external SSO (Authelia, Keycloak, etc.)?
Yes. Set `CPM_AUTH_MODE=oidc` and configure the `CPM_AUTH_OIDC_*` variables. On
first successful login CPM creates the matching user automatically (JIT). Use
`CPM_AUTH_OIDC_ALLOWEDDOMAINS` to restrict which email domains may sign in.
> How can I use CPM?
You have to compile CPM yourself. The frontend is based on ReactJS with Typescript, the compiled frontend must then be added under ``backend/assets``. The backend is written in GoLang and can be easily compiled using ``go build cmd/main.go``.
You have to compile CPM yourself. The frontend (Vite + React + TypeScript) is built from the ``frontend`` directory with ``npm install && npm run build``, which outputs straight into ``backend/embed/assets``. The backend is written in Go (1.24+) and can be compiled with ``go build ./cmd/main.go`` from the ``backend`` directory, producing a single binary with the frontend embedded. During development run ``npm run dev`` (Vite proxies ``/api`` to the Go backend on port 3001).
## Docker
A multi-stage `Dockerfile` builds the frontend, compiles the backend (CGO is
required by the SQLite driver) and produces a small Alpine runtime image that
bundles Caddy. The container entrypoint starts Caddy with its admin API and then
CPM in `api` mode.
```bash
docker build -t caddyproxymanager .
docker run -d --name cpm \
-p 3001:3001 -p 80:80 -p 443:443 \
-v cpm-data:/data \
caddyproxymanager
```
Published images are available at `ghcr.io/pacerino/caddyproxymanager`.
## Development & testing
```bash
# Backend tests
cd backend && go test ./...
# Frontend type-check + build
cd frontend && npm run build
```
CI (GitHub Actions, `.github/workflows/ci.yml`) runs the backend tests with the
race detector, builds the frontend, and on pushes to `master` / version tags
builds and publishes the Docker image to GHCR.
## Contribution
If you want to help with the development pull requests etc. are welcome!

View File

@@ -6,6 +6,7 @@ import (
"syscall"
"github.com/Pacerino/CaddyProxyManager/internal/api"
"github.com/Pacerino/CaddyProxyManager/internal/auth"
"github.com/Pacerino/CaddyProxyManager/internal/config"
"github.com/Pacerino/CaddyProxyManager/internal/database"
"github.com/Pacerino/CaddyProxyManager/internal/jobqueue"
@@ -19,6 +20,9 @@ var (
func main() {
config.Init(&version, &commit)
if err := auth.EnsureKey(); err != nil {
logger.Error("KeyBootstrapError", err)
}
database.NewDB()
jobqueue.Start()

View File

@@ -1,12 +1,11 @@
{{host.domains}} {
{{#if host.upstreams}}
{{#if host.matcher}}
reverse_proxy {{host.matcher}} {{#each host.upstreams}} {{backend}} {{/each}}
{{else}}
reverse_proxy {{#each host.upstreams}} {{backend}} {{/each}}
{{/if}}
{{#if host.matcher}}
reverse_proxy {{host.matcher}}{{#each host.upstreams}} {{backend}}{{/each}}
{{else}}
reverse_proxy{{#each host.upstreams}} {{backend}}{{/each}}
{{/if}}
{{/if}}
{{#if LogPath}}
log {
output file {{LogPath}}

View File

@@ -1,47 +1,53 @@
module github.com/Pacerino/CaddyProxyManager
go 1.19
go 1.25.0
require (
github.com/aymerick/raymond v2.0.2+incompatible
github.com/go-chi/jwtauth/v5 v5.0.2
github.com/go-playground/validator/v10 v10.11.1
github.com/golang-jwt/jwt/v4 v4.4.2
github.com/lestrrat-go/jwx v1.2.6
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
gorm.io/driver/sqlite v1.3.6
gorm.io/gorm v1.23.9
github.com/coreos/go-oidc/v3 v3.18.0
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-playground/validator/v10 v10.30.3
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/lestrrat-go/jwx/v3 v3.1.1
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/goccy/go-json v0.7.6 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/blackmagic v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.0 // indirect
github.com/lestrrat-go/iter v1.0.1 // indirect
github.com/lestrrat-go/option v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.9 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.3.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/qri-io/jsonpointer v0.1.1 // indirect
golang.org/x/text v0.3.7 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/coreos/go-systemd/v22 v22.4.0
github.com/fatih/color v1.13.0
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/cors v1.2.1
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/mattn/go-sqlite3 v1.14.15 // indirect
github.com/coreos/go-systemd/v22 v22.7.0
github.com/fatih/color v1.19.0
github.com/go-chi/chi/v5 v5.3.0
github.com/go-chi/cors v1.2.2
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/mattn/go-sqlite3 v1.14.45 // indirect
github.com/qri-io/jsonschema v0.2.1
github.com/vrischmann/envconfig v1.3.0
golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41 // indirect
github.com/vrischmann/envconfig v1.4.1
golang.org/x/sys v0.46.0 // indirect
)

View File

@@ -1,153 +1,112 @@
github.com/aymerick/raymond v2.0.2+incompatible h1:VEp3GpgdAnv9B2GFyTvqgcKvY+mfKMjPOA3SbKLtnU0=
github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/coreos/go-systemd/v22 v22.4.0 h1:y9YHcjnjynCd/DVbg5j9L/33jQM3MxJlbj/zWskzfGU=
github.com/coreos/go-systemd/v22 v22.4.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d h1:1iy2qD6JEhHKKhUOA9IWs7mjco7lnw2qx8FsRI2wirE=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/jwtauth/v5 v5.0.2 h1:CSKtr+b6Jnfy5T27sMaiBPxaVE/bjnjS3ramFQ0526w=
github.com/go-chi/jwtauth/v5 v5.0.2/go.mod h1:TeA7vmPe3uYThvHw8O8W13HOOpOd4MTgToxL41gZyjs=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/goccy/go-json v0.7.6 h1:H0wq4jppBQ+9222sk5+hPLL25abZQiRuQ6YPnjO9c+A=
github.com/goccy/go-json v0.7.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A=
github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
github.com/lestrrat-go/blackmagic v1.0.0 h1:XzdxDbuQTz0RZZEmdU7cnQxUtFUzgCSPq8RCz4BxIi4=
github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ=
github.com/lestrrat-go/codegen v1.0.1/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=
github.com/lestrrat-go/httpcc v1.0.0 h1:FszVC6cKfDvBKcJv646+lkh4GydQg2Z29scgUfkOpYc=
github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A=
github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
github.com/lestrrat-go/jwx v1.2.6 h1:XAgfuHaOB7fDZ/6WhVgl8K89af768dU+3Nx4DlTbLIk=
github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q=
github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/qri-io/jsonpointer v0.1.1 h1:prVZBZLL6TW5vsSB9fFHFAMBLI4b0ri5vribQlTJiBA=
github.com/qri-io/jsonpointer v0.1.1/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64=
github.com/qri-io/jsonschema v0.2.1 h1:NNFoKms+kut6ABPf6xiKNM5214jzxAhDBrPHCJ97Wg0=
github.com/qri-io/jsonschema v0.2.1/go.mod h1:g7DPkiOsK1xv6T/Ao5scXRkd+yTFygcANPBaaqW+VrI=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/vrischmann/envconfig v1.3.0 h1:4XIvQTXznxmWMnjouj0ST5lFo/WAYf5Exgl3x82crEk=
github.com/vrischmann/envconfig v1.3.0/go.mod h1:bbvxFYJdRSpXrhS63mBFtKJzkDiNkyArOLXtY6q0kuI=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201217014255-9d1352758620/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41 h1:ohgcoMbSofXygzo6AD2I1kz3BFmW1QArPYTtwEM3UXc=
golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
golang.org/x/tools v0.0.0-20210114065538-d78b04bdf963/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vrischmann/envconfig v1.4.1 h1:fucz2HsoAkJCLgIngWdWqLNxNjdWD14zfrLF6EQPdY4=
github.com/vrischmann/envconfig v1.4.1/go.mod h1:cX3p+/PEssil6fWwzIS7kf8iFpli3giuxXGHxckucYc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.23.9 h1:NSHG021i+MCznokeXR3udGaNyFyBQJW8MbjrJMVCfGw=
gorm.io/gorm v1.23.9/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -0,0 +1,109 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"net/http"
"net/url"
h "github.com/Pacerino/CaddyProxyManager/internal/api/http"
"github.com/Pacerino/CaddyProxyManager/internal/auth"
"github.com/Pacerino/CaddyProxyManager/internal/logger"
)
const oidcStateCookie = "cpm_oidc_state"
// authConfigResponse tells the frontend which auth mode is active.
type authConfigResponse struct {
Mode string `json:"mode"`
}
// AuthConfig returns the active authentication mode.
// Route: GET /auth/config
func (s Handler) AuthConfig() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
mode := "local"
if auth.IsOIDCEnabled() {
mode = "oidc"
}
h.ResultResponseJSON(w, r, http.StatusOK, authConfigResponse{Mode: mode})
}
}
// OIDCLogin redirects the user to the OIDC provider.
// Route: GET /auth/oidc/login
func (s Handler) OIDCLogin() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if !auth.IsOIDCEnabled() {
h.ResultErrorJSON(w, r, http.StatusBadRequest, "oidc is not enabled", nil)
return
}
state, err := randomState()
if err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
return
}
authURL, err := auth.OIDCAuthURL(state)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
return
}
http.SetCookie(w, &http.Cookie{
Name: oidcStateCookie,
Value: state,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
MaxAge: 300,
})
http.Redirect(w, r, authURL, http.StatusFound)
}
}
// OIDCCallback handles the provider redirect, exchanges the code and redirects
// back to the SPA with a CPM-issued token.
// Route: GET /auth/oidc/callback
func (s Handler) OIDCCallback() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if !auth.IsOIDCEnabled() {
h.ResultErrorJSON(w, r, http.StatusBadRequest, "oidc is not enabled", nil)
return
}
stateCookie, err := r.Cookie(oidcStateCookie)
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
h.ResultErrorJSON(w, r, http.StatusBadRequest, "invalid oauth state", nil)
return
}
// Clear the state cookie.
http.SetCookie(w, &http.Cookie{Name: oidcStateCookie, Value: "", Path: "/", MaxAge: -1})
code := r.URL.Query().Get("code")
if code == "" {
h.ResultErrorJSON(w, r, http.StatusBadRequest, "missing authorization code", nil)
return
}
jwtData, err := auth.OIDCCallback(r.Context(), code)
if err != nil {
logger.Error("OIDCCallbackError", err)
http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), http.StatusFound)
return
}
// Hand the token back to the SPA via the callback route.
redirect := "/login/callback#token=" + url.QueryEscape(jwtData.Token)
http.Redirect(w, r, redirect, http.StatusFound)
}
}
// randomState returns a cryptographically random state string.
func randomState() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@@ -4,10 +4,36 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
)
// validate is the shared validator with CPM's custom rules registered.
var validate = newValidator()
func newValidator() *validator.Validate {
v := validator.New()
// "domains" validates a space/comma separated list where each entry is a
// valid FQDN or host:port.
_ = v.RegisterValidation("domains", func(fl validator.FieldLevel) bool {
fields := strings.FieldsFunc(fl.Field().String(), func(r rune) bool {
return r == ' ' || r == ',' || r == '\t'
})
if len(fields) == 0 {
return false
}
for _, d := range fields {
if v.Var(d, "fqdn") != nil && v.Var(d, "hostname_port") != nil {
return false
}
}
return true
})
return v
}
func getURLParamInt(r *http.Request, varName string) (int, error) {
required := true
defaultValue := 0

View File

@@ -9,7 +9,6 @@ import (
"github.com/Pacerino/CaddyProxyManager/internal/database"
"github.com/Pacerino/CaddyProxyManager/internal/jobqueue"
"github.com/go-playground/validator/v10"
"gorm.io/gorm"
)
@@ -55,7 +54,6 @@ func (s Handler) CreateHost() func(http.ResponseWriter, *http.Request) {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
validate := validator.New()
if err := validate.Struct(newHost); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
@@ -67,10 +65,16 @@ func (s Handler) CreateHost() func(http.ResponseWriter, *http.Request) {
return
}
provider, err := caddy.GetProvider()
if err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
return
}
if err := jobqueue.AddJob(jobqueue.Job{
Name: "CaddyConfigureHost",
Action: func() error {
return caddy.WriteHost(newHost)
return provider.WriteHost(newHost)
},
}); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
@@ -91,16 +95,21 @@ func (s Handler) UpdateHost() func(http.ResponseWriter, *http.Request) {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
validate := validator.New()
if err := validate.Struct(newHost); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
provider, err := caddy.GetProvider()
if err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
return
}
if err := jobqueue.AddJob(jobqueue.Job{
Name: "CaddyConfigureHost",
Action: func() error {
return caddy.WriteHost(newHost)
return provider.WriteHost(newHost)
},
}); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
@@ -128,10 +137,16 @@ func (s Handler) DeleteHost() func(http.ResponseWriter, *http.Request) {
return
}
provider, err := caddy.GetProvider()
if err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
return
}
if err := jobqueue.AddJob(jobqueue.Job{
Name: "CaddyConfigureHost",
Action: func() error {
return caddy.RemoveHost(hostID)
return provider.RemoveHost(hostID)
},
}); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)

View File

@@ -9,7 +9,6 @@ import (
"github.com/Pacerino/CaddyProxyManager/internal/auth"
"github.com/Pacerino/CaddyProxyManager/internal/database"
"github.com/Pacerino/CaddyProxyManager/internal/logger"
"github.com/go-playground/validator/v10"
"gorm.io/gorm"
)
@@ -22,13 +21,16 @@ type LoginData struct {
// Route: GET /users/login
func (s Handler) UserLogin() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if auth.IsOIDCEnabled() {
h.ResultErrorJSON(w, r, http.StatusBadRequest, "local login is disabled, use OIDC", nil)
return
}
var logindata LoginData
err := json.NewDecoder(r.Body).Decode(&logindata)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
validate := validator.New()
if err := validate.Struct(logindata); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
@@ -95,7 +97,6 @@ func (s Handler) CreateUser() func(http.ResponseWriter, *http.Request) {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
validate := validator.New()
if err := validate.Struct(&newUser); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
@@ -151,7 +152,6 @@ func (s Handler) UpdateUser() func(http.ResponseWriter, *http.Request) {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
validate := validator.New()
if err := validate.Struct(newUser); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return

View File

@@ -0,0 +1,39 @@
package handler
import (
"testing"
"github.com/Pacerino/CaddyProxyManager/internal/database"
)
func TestDomainsValidator(t *testing.T) {
tests := []struct {
name string
domains string
wantErr bool
}{
{"single fqdn", "example.com", false},
{"multiple space separated", "example.com www.example.com", false},
{"comma separated", "example.com,www.example.com", false},
{"host:port", "example.com:8080", false},
{"empty", "", true},
{"invalid domain", "not a domain!", true},
{"one invalid among valid", "example.com bad domain", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := database.Host{
Domains: tt.domains,
Upstreams: []database.Upstream{{Backend: "127.0.0.1:8080"}},
}
err := validate.Struct(h)
if tt.wantErr && err == nil {
t.Errorf("domains %q: expected validation error, got none", tt.domains)
}
if !tt.wantErr && err != nil {
t.Errorf("domains %q: unexpected error %v", tt.domains, err)
}
})
}
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/Pacerino/CaddyProxyManager/internal/auth"
"github.com/Pacerino/CaddyProxyManager/internal/logger"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/jwt"
"github.com/lestrrat-go/jwx/v3/jwt"
)
// DecodeAuth decodes an auth header

View File

@@ -8,6 +8,8 @@ import (
"github.com/Pacerino/CaddyProxyManager/embed"
"github.com/Pacerino/CaddyProxyManager/internal/api/handler"
"github.com/Pacerino/CaddyProxyManager/internal/api/middleware"
"github.com/Pacerino/CaddyProxyManager/internal/config"
"github.com/Pacerino/CaddyProxyManager/internal/logger"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
@@ -45,6 +47,13 @@ func generateRoutes(r chi.Router, h *handler.Handler) chi.Router {
r.Put("/", h.UpdateHost()) // Update Host by ID
})
//Auth
r.Route("/auth", func(r chi.Router) {
r.Get("/config", h.AuthConfig()) // Which auth mode is active
r.Get("/oidc/login", h.OIDCLogin()) // Begin OIDC login
r.Get("/oidc/callback", h.OIDCCallback()) // OIDC provider redirect
})
//User
r.Route("/users", func(r chi.Router) {
r.Post("/login", h.UserLogin()) // Login a User
@@ -59,15 +68,37 @@ func generateRoutes(r chi.Router, h *handler.Handler) chi.Router {
return r
}
// frontendFS returns the filesystem used to serve the SPA. When
// CPM_FRONTENDDIR is set the assets are served from disk, otherwise the
// assets embedded in the binary are used.
func frontendFS() http.FileSystem {
if dir := config.Configuration.FrontendDir; dir != "" {
logger.Info("Serving frontend from external directory: %s", dir)
return http.Dir(dir)
}
fSys, err := fs.Sub(embed.Assets, "assets")
if err != nil {
panic(err)
}
return http.FS(fSys)
}
func fileServer(r chi.Router) {
root := frontendFS()
fileSrv := http.FileServer(root)
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
rctx := chi.RouteContext(r.Context())
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
fSys, err := fs.Sub(embed.Assets, "assets")
if err != nil {
panic(err)
// Serve the requested file if it exists, otherwise fall back to
// index.html so client-side routing works (SPA behaviour).
upath := strings.TrimPrefix(r.URL.Path, "/")
if upath == "" {
upath = "index.html"
}
fs := http.StripPrefix(pathPrefix, http.FileServer(http.FS(fSys)))
fs.ServeHTTP(w, r)
if f, err := root.Open(upath); err != nil {
r.URL.Path = "/"
} else {
f.Close()
}
fileSrv.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,33 @@
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"github.com/Pacerino/CaddyProxyManager/internal/config"
"github.com/Pacerino/CaddyProxyManager/internal/logger"
)
// EnsureKey generates an RSA private key for signing JWTs if one does not
// already exist in the data folder.
func EnsureKey() error {
path := fmt.Sprintf("%s/jwtkey.pem", config.Configuration.DataFolder)
if _, err := os.Stat(path); err == nil {
return nil
}
logger.Info("Generating new JWT signing key at %s", path)
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
encoded := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
return os.WriteFile(path, encoded, 0600)
}

View File

@@ -0,0 +1,166 @@
package auth
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"github.com/Pacerino/CaddyProxyManager/internal/config"
"github.com/Pacerino/CaddyProxyManager/internal/database"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
var (
oidcOnce sync.Once
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oidcConfig oauth2.Config
oidcInitErr error
)
// IsOIDCEnabled reports whether the auth mode is configured as oidc.
func IsOIDCEnabled() bool {
return strings.ToLower(config.Configuration.Auth.Mode) == "oidc"
}
// initOIDC performs provider discovery exactly once.
func initOIDC() error {
oidcOnce.Do(func() {
c := config.Configuration.Auth.OIDC
if c.Issuer == "" || c.ClientID == "" || c.RedirectURL == "" {
oidcInitErr = errors.New("oidc is not fully configured (issuer, client id and redirect url are required)")
return
}
provider, err := oidc.NewProvider(context.Background(), c.Issuer)
if err != nil {
oidcInitErr = fmt.Errorf("oidc provider discovery failed: %w", err)
return
}
oidcProvider = provider
oidcVerifier = provider.Verifier(&oidc.Config{ClientID: c.ClientID})
oidcConfig = oauth2.Config{
ClientID: c.ClientID,
ClientSecret: c.ClientSecret,
RedirectURL: c.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: c.Scopes,
}
})
return oidcInitErr
}
// OIDCAuthURL returns the provider authorization URL for the given state.
func OIDCAuthURL(state string) (string, error) {
if err := initOIDC(); err != nil {
return "", err
}
return oidcConfig.AuthCodeURL(state), nil
}
// oidcClaims is the subset of ID token claims CPM consumes.
type oidcClaims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
}
// OIDCCallback exchanges the authorization code, verifies the ID token,
// JIT-provisions the matching CPM user and returns a CPM-issued JWT.
func OIDCCallback(ctx context.Context, code string) (GeneratedResponse, error) {
if err := initOIDC(); err != nil {
return GeneratedResponse{}, err
}
token, err := oidcConfig.Exchange(ctx, code)
if err != nil {
return GeneratedResponse{}, fmt.Errorf("token exchange failed: %w", err)
}
rawID, ok := token.Extra("id_token").(string)
if !ok {
return GeneratedResponse{}, errors.New("no id_token in token response")
}
idToken, err := oidcVerifier.Verify(ctx, rawID)
if err != nil {
return GeneratedResponse{}, fmt.Errorf("id token verification failed: %w", err)
}
var claims oidcClaims
if err := idToken.Claims(&claims); err != nil {
return GeneratedResponse{}, fmt.Errorf("failed to parse claims: %w", err)
}
if claims.Email == "" {
return GeneratedResponse{}, errors.New("id token has no email claim")
}
if !isAllowedDomain(claims.Email) {
return GeneratedResponse{}, errors.New("email domain is not allowed")
}
user, err := provisionOIDCUser(claims)
if err != nil {
return GeneratedResponse{}, err
}
return Generate(user)
}
// isAllowedDomain checks the email against the configured allowlist.
// An empty allowlist permits any domain.
func isAllowedDomain(email string) bool {
allowed := config.Configuration.Auth.OIDC.AllowedDomains
if len(allowed) == 0 {
return true
}
at := strings.LastIndex(email, "@")
if at < 0 {
return false
}
domain := strings.ToLower(email[at+1:])
for _, d := range allowed {
if strings.ToLower(strings.TrimSpace(d)) == domain {
return true
}
}
return false
}
// provisionOIDCUser finds or creates (JIT) the CPM user for the claims.
func provisionOIDCUser(claims oidcClaims) (*database.User, error) {
db := database.GetInstance()
var user database.User
// Match by subject first, then fall back to email.
tx := db.Where("subject = ? AND provider = ?", claims.Subject, "oidc").First(&user)
if tx.Error != nil {
tx = db.Where("email = ?", claims.Email).First(&user)
}
name := claims.Name
if name == "" {
name = claims.Email
}
if tx.Error != nil {
// Create a new OIDC user.
user = database.User{
Name: name,
Email: claims.Email,
Provider: "oidc",
Subject: claims.Subject,
}
if err := db.Create(&user).Error; err != nil {
return nil, fmt.Errorf("failed to provision oidc user: %w", err)
}
return &user, nil
}
// Update existing user to keep subject/provider in sync.
user.Provider = "oidc"
user.Subject = claims.Subject
user.Name = name
db.Save(&user)
return &user, nil
}

View File

@@ -0,0 +1,54 @@
package auth
import (
"testing"
"github.com/Pacerino/CaddyProxyManager/internal/config"
)
func TestIsAllowedDomain(t *testing.T) {
original := config.Configuration.Auth.OIDC.AllowedDomains
t.Cleanup(func() { config.Configuration.Auth.OIDC.AllowedDomains = original })
tests := []struct {
name string
allowed []string
email string
want bool
}{
{"empty allowlist permits all", nil, "user@anything.com", true},
{"exact match", []string{"example.com"}, "user@example.com", true},
{"case insensitive", []string{"Example.COM"}, "user@example.com", true},
{"whitespace trimmed", []string{" example.com "}, "user@example.com", true},
{"not allowed", []string{"example.com"}, "user@evil.com", false},
{"no at sign", []string{"example.com"}, "notanemail", false},
{"one of many", []string{"a.com", "b.com"}, "user@b.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config.Configuration.Auth.OIDC.AllowedDomains = tt.allowed
if got := isAllowedDomain(tt.email); got != tt.want {
t.Errorf("isAllowedDomain(%q) with %v = %v, want %v", tt.email, tt.allowed, got, tt.want)
}
})
}
}
func TestIsOIDCEnabled(t *testing.T) {
original := config.Configuration.Auth.Mode
t.Cleanup(func() { config.Configuration.Auth.Mode = original })
config.Configuration.Auth.Mode = "oidc"
if !IsOIDCEnabled() {
t.Error("expected oidc enabled")
}
config.Configuration.Auth.Mode = "OIDC"
if !IsOIDCEnabled() {
t.Error("expected oidc enabled (case-insensitive)")
}
config.Configuration.Auth.Mode = "local"
if IsOIDCEnabled() {
t.Error("expected oidc disabled")
}
}

View File

@@ -0,0 +1,167 @@
package caddy
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Pacerino/CaddyProxyManager/internal/config"
"github.com/Pacerino/CaddyProxyManager/internal/database"
)
// APIProvider manages Caddy's configuration through its admin API by
// maintaining one JSON route per host.
type APIProvider struct {
baseURL string
serverName string
listen []string
client *http.Client
}
// NewAPIProvider builds an APIProvider from the global configuration.
func NewAPIProvider() *APIProvider {
return &APIProvider{
baseURL: strings.TrimRight(config.Configuration.Caddy.AdminURL, "/"),
serverName: config.Configuration.Caddy.ServerName,
listen: config.Configuration.Caddy.Listen,
client: &http.Client{Timeout: 15 * time.Second},
}
}
// WriteHost creates or updates the route for a host.
func (p *APIProvider) WriteHost(h database.Host) error {
if err := p.ensureServer(); err != nil {
return err
}
route := buildRoute(h)
id := hostRouteID(h.ID)
exists, err := p.routeExists(id)
if err != nil {
return err
}
if exists {
// Replace the existing route in place via its @id.
return p.do(http.MethodPatch, "/id/"+id, route, nil)
}
// Append a new route to the server's route list.
path := fmt.Sprintf("/config/apps/http/servers/%s/routes", p.serverName)
return p.do(http.MethodPost, path, route, nil)
}
// RemoveHost deletes the route for a host.
func (p *APIProvider) RemoveHost(hostID int) error {
id := hostRouteID(uint(hostID))
exists, err := p.routeExists(id)
if err != nil {
return err
}
if !exists {
return nil
}
return p.do(http.MethodDelete, "/id/"+id, nil, nil)
}
// configExists reports whether the config at path is present. Caddy returns
// HTTP 200 with a literal "null" body for a valid-but-empty traversal path, so
// a successful status alone is not enough.
func (p *APIProvider) configExists(path string) (bool, error) {
status, body, err := p.request(http.MethodGet, path, nil)
if err != nil {
return false, err
}
if status < 200 || status >= 300 {
// Unknown/invalid path: treat as not present.
return false, nil
}
trimmed := strings.TrimSpace(string(body))
if trimmed == "" || trimmed == "null" {
return false, nil
}
return true, nil
}
// routeExists reports whether a route with the given @id is present.
func (p *APIProvider) routeExists(id string) (bool, error) {
return p.configExists("/id/" + id)
}
// ensureServer makes sure the http app and target server exist so routes can
// be appended. It is a no-op when the server is already present.
func (p *APIProvider) ensureServer() error {
exists, err := p.configExists(fmt.Sprintf("/config/apps/http/servers/%s", p.serverName))
if err != nil {
return err
}
if exists {
return nil
}
// Bootstrap a minimal http app with an empty server.
server := map[string]any{
"listen": p.listen,
"routes": []any{},
}
httpApp := map[string]any{
"servers": map[string]any{p.serverName: server},
}
// PATCH the http app so an existing (empty) app is merged rather than
// rejected with a 409 conflict like PUT would be.
return p.do(http.MethodPatch, "/config/apps/http", httpApp, nil)
}
// do sends a request and treats any non-2xx status as an error.
func (p *APIProvider) do(method, path string, body any, out any) error {
status, data, err := p.request(method, path, body)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("caddy admin %s %s returned %d: %s", method, path, status, string(data))
}
if out != nil && len(data) > 0 {
return json.Unmarshal(data, out)
}
return nil
}
// request performs an admin API call and returns the status code and body.
func (p *APIProvider) request(method, path string, body any) (int, []byte, error) {
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return 0, nil, err
}
reader = bytes.NewReader(buf)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, reader)
if err != nil {
return 0, nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := p.client.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, err
}
return resp.StatusCode, data, nil
}

View File

@@ -0,0 +1,164 @@
package caddy
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Pacerino/CaddyProxyManager/internal/database"
)
// fakeCaddy is a minimal in-memory stand-in for the Caddy admin API that
// mimics the quirks the real API exhibits (200 + "null" body for absent paths).
type fakeCaddy struct {
serverExists bool
routes map[string]bool // route id -> exists
calls []string // method+path log for assertions
}
func newFakeCaddy() *fakeCaddy {
return &fakeCaddy{routes: map[string]bool{}}
}
func (f *fakeCaddy) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.calls = append(f.calls, r.Method+" "+r.URL.Path)
path := r.URL.Path
switch {
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/apps/http/servers/"):
// Caddy returns 200 + "null" when the server is absent.
if f.serverExists {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"listen":[":8080"],"routes":[]}`))
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte("null"))
}
case r.Method == http.MethodGet && strings.HasPrefix(path, "/id/"):
id := strings.TrimPrefix(path, "/id/")
w.WriteHeader(http.StatusOK)
if f.routes[id] {
w.Write([]byte(`{"@id":"` + id + `"}`))
} else {
w.Write([]byte("null"))
}
case r.Method == http.MethodPatch && path == "/config/apps/http":
f.serverExists = true
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && strings.HasSuffix(path, "/routes"):
// New route appended; mark generic existence is handled by id GET.
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/id/"):
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/id/"):
id := strings.TrimPrefix(path, "/id/")
delete(f.routes, id)
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusBadRequest)
}
})
}
func newTestProvider(url string) *APIProvider {
return &APIProvider{
baseURL: strings.TrimRight(url, "/"),
serverName: "srv0",
listen: []string{":8080"},
client: &http.Client{Timeout: 2 * time.Second},
}
}
func sampleHost() database.Host {
h := database.Host{
Domains: "example.com",
Upstreams: []database.Upstream{{Backend: "127.0.0.1:8080"}},
}
h.ID = 1
return h
}
func TestAPIProviderBootstrapsServerOnFirstWrite(t *testing.T) {
fake := newFakeCaddy()
srv := httptest.NewServer(fake.handler())
defer srv.Close()
p := newTestProvider(srv.URL)
if err := p.WriteHost(sampleHost()); err != nil {
t.Fatalf("WriteHost: %v", err)
}
if !fake.serverExists {
t.Fatal("expected server to be bootstrapped via PATCH")
}
assertCalled(t, fake, "PATCH /config/apps/http")
assertCalled(t, fake, "POST /config/apps/http/servers/srv0/routes")
}
func TestAPIProviderUpdatesExistingRoute(t *testing.T) {
fake := newFakeCaddy()
fake.serverExists = true
fake.routes["cpm_host_1"] = true
srv := httptest.NewServer(fake.handler())
defer srv.Close()
p := newTestProvider(srv.URL)
if err := p.WriteHost(sampleHost()); err != nil {
t.Fatalf("WriteHost: %v", err)
}
// Existing route -> PATCH /id/, not a POST.
assertCalled(t, fake, "PATCH /id/cpm_host_1")
for _, c := range fake.calls {
if strings.HasPrefix(c, "POST ") {
t.Errorf("did not expect POST on update, got %q", c)
}
}
}
func TestAPIProviderRemoveHost(t *testing.T) {
fake := newFakeCaddy()
fake.serverExists = true
fake.routes["cpm_host_1"] = true
srv := httptest.NewServer(fake.handler())
defer srv.Close()
p := newTestProvider(srv.URL)
if err := p.RemoveHost(1); err != nil {
t.Fatalf("RemoveHost: %v", err)
}
assertCalled(t, fake, "DELETE /id/cpm_host_1")
if fake.routes["cpm_host_1"] {
t.Error("route should have been deleted")
}
}
func TestAPIProviderRemoveMissingHostIsNoop(t *testing.T) {
fake := newFakeCaddy()
fake.serverExists = true
srv := httptest.NewServer(fake.handler())
defer srv.Close()
p := newTestProvider(srv.URL)
if err := p.RemoveHost(99); err != nil {
t.Fatalf("RemoveHost on missing should be noop, got %v", err)
}
for _, c := range fake.calls {
if strings.HasPrefix(c, "DELETE ") {
t.Errorf("expected no DELETE for missing host, got %q", c)
}
}
}
func assertCalled(t *testing.T, f *fakeCaddy, want string) {
t.Helper()
for _, c := range f.calls {
if c == want {
return
}
}
t.Errorf("expected call %q, calls were: %v", want, f.calls)
}

View File

@@ -12,17 +12,26 @@ import (
"github.com/aymerick/raymond"
)
// CaddyfileProvider applies host configuration by rendering a per-host
// Caddyfile snippet into the data folder and reloading Caddy.
type CaddyfileProvider struct{}
type hostEntity struct {
Host database.Host
LogPath string
}
func WriteHost(h database.Host) error {
func hostConfigPath(hostID uint) string {
return fmt.Sprintf("%s/host_%d.conf", config.Configuration.DataFolder, hostID)
}
// WriteHost renders the host template and writes it to the config folder.
func (p *CaddyfileProvider) WriteHost(h database.Host) error {
data := &hostEntity{
Host: h,
LogPath: fmt.Sprintf("%s/host_%d.log", config.Configuration.LogFolder, h.ID),
}
filename := fmt.Sprintf("%s/host_%d.conf", config.Configuration.DataFolder, h.ID)
filename := hostConfigPath(h.ID)
// Read Template from Embed FS
template, err := embed.CaddyFiles.ReadFile("caddy/host.hbs")
if err != nil {
@@ -41,13 +50,14 @@ func WriteHost(h database.Host) error {
return err
}
return ReloadCaddy()
return Reload()
}
func RemoveHost(hostID int) error {
filename := fmt.Sprintf("%s/host_%d.conf", config.Configuration.DataFolder, hostID)
if err := os.Remove(filename); err != nil {
// RemoveHost deletes the per-host config file and reloads Caddy.
func (p *CaddyfileProvider) RemoveHost(hostID int) error {
filename := hostConfigPath(uint(hostID))
if err := os.Remove(filename); err != nil && !os.IsNotExist(err) {
return err
}
return ReloadCaddy()
return Reload()
}

View File

@@ -1,28 +0,0 @@
package caddy
import (
"context"
"time"
"github.com/Pacerino/CaddyProxyManager/internal/logger"
"github.com/coreos/go-systemd/v22/dbus"
)
func ReloadCaddy() error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := dbus.NewSystemdConnectionContext(ctx)
if err != nil {
return err
}
defer conn.Close()
resChan := make(chan string)
_, err = conn.ReloadOrRestartUnitContext(ctx, "caddy.service", "replace", resChan)
if err != nil {
logger.Error("", err)
return err
}
<-resChan
return nil
}

View File

@@ -0,0 +1,50 @@
package caddy
import (
"fmt"
"strings"
"github.com/Pacerino/CaddyProxyManager/internal/database"
)
// hostRouteID returns the @id used to address a host's route in Caddy's config.
func hostRouteID(hostID uint) string {
return fmt.Sprintf("cpm_host_%d", hostID)
}
// buildRoute converts a Host into a Caddy JSON route object.
//
// The resulting route matches the host's domains and reverse-proxies matching
// requests to the configured upstreams. It is tagged with an @id so it can be
// addressed individually through the admin API.
func buildRoute(h database.Host) map[string]any {
hosts := splitDomains(h.Domains)
upstreams := make([]map[string]any, 0, len(h.Upstreams))
for _, u := range h.Upstreams {
upstreams = append(upstreams, map[string]any{"dial": u.Backend})
}
handler := map[string]any{
"handler": "reverse_proxy",
"upstreams": upstreams,
}
route := map[string]any{
"@id": hostRouteID(h.ID),
"handle": []map[string]any{handler},
"match": []map[string]any{{"host": hosts}},
"terminal": true,
}
return route
}
// splitDomains turns a space separated domain list into a slice.
func splitDomains(domains string) []string {
fields := strings.Fields(domains)
if len(fields) == 0 {
return []string{}
}
return fields
}

View File

@@ -0,0 +1,67 @@
package caddy
import (
"reflect"
"testing"
"github.com/Pacerino/CaddyProxyManager/internal/database"
)
func TestHostRouteID(t *testing.T) {
if got := hostRouteID(42); got != "cpm_host_42" {
t.Fatalf("hostRouteID(42) = %q, want cpm_host_42", got)
}
}
func TestSplitDomains(t *testing.T) {
tests := []struct {
in string
want []string
}{
{"", []string{}},
{" ", []string{}},
{"example.com", []string{"example.com"}},
{"a.com b.com", []string{"a.com", "b.com"}},
{" a.com b.com ", []string{"a.com", "b.com"}},
}
for _, tt := range tests {
if got := splitDomains(tt.in); !reflect.DeepEqual(got, tt.want) {
t.Errorf("splitDomains(%q) = %v, want %v", tt.in, got, tt.want)
}
}
}
func TestBuildRoute(t *testing.T) {
h := database.Host{
Domains: "example.com www.example.com",
Upstreams: []database.Upstream{
{Backend: "127.0.0.1:8080"},
{Backend: "127.0.0.1:8081"},
},
}
h.ID = 7
route := buildRoute(h)
if route["@id"] != "cpm_host_7" {
t.Errorf("@id = %v, want cpm_host_7", route["@id"])
}
if route["terminal"] != true {
t.Errorf("terminal = %v, want true", route["terminal"])
}
match := route["match"].([]map[string]any)
hosts := match[0]["host"].([]string)
if !reflect.DeepEqual(hosts, []string{"example.com", "www.example.com"}) {
t.Errorf("hosts = %v", hosts)
}
handle := route["handle"].([]map[string]any)
if handle[0]["handler"] != "reverse_proxy" {
t.Errorf("handler = %v, want reverse_proxy", handle[0]["handler"])
}
ups := handle[0]["upstreams"].([]map[string]any)
if len(ups) != 2 || ups[0]["dial"] != "127.0.0.1:8080" || ups[1]["dial"] != "127.0.0.1:8081" {
t.Errorf("upstreams = %v", ups)
}
}

View File

@@ -0,0 +1,31 @@
package caddy
import (
"fmt"
"strings"
"github.com/Pacerino/CaddyProxyManager/internal/config"
"github.com/Pacerino/CaddyProxyManager/internal/database"
)
// Provider applies host configuration to Caddy. Implementations either write
// per-host Caddyfiles (CaddyfileProvider) or manage Caddy's JSON config through
// its admin API (APIProvider).
type Provider interface {
// WriteHost creates or updates the configuration for a host.
WriteHost(h database.Host) error
// RemoveHost removes the configuration for a host.
RemoveHost(hostID int) error
}
// GetProvider returns the configured Provider based on CPM_CADDY_MODE.
func GetProvider() (Provider, error) {
switch strings.ToLower(config.Configuration.Caddy.Mode) {
case "", "caddyfile":
return &CaddyfileProvider{}, nil
case "api":
return NewAPIProvider(), nil
default:
return nil, fmt.Errorf("unknown caddy mode %q (expected \"caddyfile\" or \"api\")", config.Configuration.Caddy.Mode)
}
}

View File

@@ -0,0 +1,54 @@
package caddy
import (
"testing"
"github.com/Pacerino/CaddyProxyManager/internal/config"
)
func TestGetProvider(t *testing.T) {
original := config.Configuration.Caddy.Mode
t.Cleanup(func() { config.Configuration.Caddy.Mode = original })
tests := []struct {
mode string
want string
wantErr bool
}{
{"", "*caddy.CaddyfileProvider", false},
{"caddyfile", "*caddy.CaddyfileProvider", false},
{"CADDYFILE", "*caddy.CaddyfileProvider", false},
{"api", "*caddy.APIProvider", false},
{"API", "*caddy.APIProvider", false},
{"bogus", "", true},
}
for _, tt := range tests {
config.Configuration.Caddy.Mode = tt.mode
p, err := GetProvider()
if tt.wantErr {
if err == nil {
t.Errorf("mode %q: expected error, got none", tt.mode)
}
continue
}
if err != nil {
t.Errorf("mode %q: unexpected error %v", tt.mode, err)
continue
}
if got := typeName(p); got != tt.want {
t.Errorf("mode %q: got %s, want %s", tt.mode, got, tt.want)
}
}
}
func typeName(v any) string {
switch v.(type) {
case *CaddyfileProvider:
return "*caddy.CaddyfileProvider"
case *APIProvider:
return "*caddy.APIProvider"
default:
return "unknown"
}
}

View File

@@ -0,0 +1,103 @@
package caddy
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/Pacerino/CaddyProxyManager/internal/config"
"github.com/Pacerino/CaddyProxyManager/internal/logger"
"github.com/coreos/go-systemd/v22/dbus"
)
// Reload triggers Caddy to pick up configuration changes using the strategy
// configured via CPM_CADDY_RELOADSTRATEGY.
func Reload() error {
switch strings.ToLower(config.Configuration.Caddy.ReloadStrategy) {
case "", "systemd":
return reloadSystemd()
case "exec":
return reloadExec()
case "api":
return reloadAPI()
case "none":
return nil
default:
return fmt.Errorf("unknown reload strategy %q (expected systemd, exec, api or none)", config.Configuration.Caddy.ReloadStrategy)
}
}
// reloadSystemd reloads the caddy unit over the systemd D-Bus interface.
func reloadSystemd() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := dbus.NewSystemdConnectionContext(ctx)
if err != nil {
return err
}
defer conn.Close()
resChan := make(chan string)
_, err = conn.ReloadOrRestartUnitContext(ctx, config.Configuration.Caddy.Service, "replace", resChan)
if err != nil {
logger.Error("CaddyReloadError", err)
return err
}
<-resChan
return nil
}
// reloadExec runs `caddy reload --config <Caddyfile>` to apply changes.
func reloadExec() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx,
config.Configuration.Caddy.Binary,
"reload",
"--config", config.Configuration.CaddyFile,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
logger.Error("CaddyReloadError", err)
return err
}
return nil
}
// reloadAPI loads the on-disk Caddyfile through the admin API's /load endpoint.
func reloadAPI() error {
data, err := os.ReadFile(config.Configuration.CaddyFile)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
url := strings.TrimRight(config.Configuration.Caddy.AdminURL, "/") + "/load"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "text/caddyfile")
resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.Error("CaddyReloadError", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("caddy /load returned %d: %s", resp.StatusCode, string(body))
}
return nil
}

View File

@@ -12,10 +12,72 @@ type log struct {
Format string `json:"format" envconfig:"optional,default=nice"`
}
// caddy holds the configuration for how CPM talks to Caddy.
type caddy struct {
// Mode selects the provider used to apply host configuration.
// Valid values: "caddyfile" (render per-host Caddyfiles) or "api"
// (manage Caddy's JSON config via the admin API).
Mode string `json:"mode" envconfig:"optional,default=caddyfile"`
// AdminURL is the base URL of the Caddy admin API (used by the api
// provider and the "api" reload strategy).
AdminURL string `json:"admin_url" envconfig:"optional,default=http://localhost:2019"`
// ServerName is the name of the http server in Caddy's JSON config
// that the api provider manages routes under.
ServerName string `json:"server_name" envconfig:"optional,default=srv0"`
// Listen are the addresses the bootstrapped server listens on when the
// api provider has to create it. Defaults to the standard HTTP/HTTPS
// ports; use e.g. :8080 for local non-root testing.
Listen []string `json:"listen" envconfig:"optional,default=:80;:443"`
// ReloadStrategy controls how the caddyfile provider triggers a reload.
// Valid values: "systemd", "exec", "api" or "none".
ReloadStrategy string `json:"reload_strategy" envconfig:"optional,default=systemd"`
// Binary is the caddy executable used by the "exec" reload strategy.
Binary string `json:"binary" envconfig:"optional,default=caddy"`
// Service is the systemd unit reloaded by the "systemd" strategy.
Service string `json:"service" envconfig:"optional,default=caddy.service"`
}
// oidc holds the OpenID Connect / OAuth2 configuration used when the auth
// mode is set to "oidc".
type oidc struct {
// Issuer is the OIDC issuer URL (used for provider discovery).
Issuer string `json:"issuer" envconfig:"optional"`
// ClientID and ClientSecret identify CPM to the provider.
ClientID string `json:"client_id" envconfig:"optional"`
ClientSecret string `json:"client_secret" envconfig:"optional"`
// RedirectURL is the CPM callback URL registered with the provider,
// e.g. https://cpm.example.com/api/auth/oidc/callback.
RedirectURL string `json:"redirect_url" envconfig:"optional"`
// Scopes requested from the provider.
Scopes []string `json:"scopes" envconfig:"optional,default=openid;profile;email"`
// AllowedDomains optionally restricts JIT provisioning to email domains.
// Empty means any successfully authenticated user is allowed.
AllowedDomains []string `json:"allowed_domains" envconfig:"optional"`
}
// auth holds the authentication configuration.
type auth struct {
// Mode selects the authentication method: "local" (email + password)
// or "oidc" (external identity provider).
Mode string `json:"mode" envconfig:"optional,default=local"`
OIDC oidc `json:"oidc"`
}
// admin holds the seed credentials for the default local admin user, created
// on first startup when the database is empty.
type admin struct {
Email string `json:"email" envconfig:"optional,default=admin@example.com"`
Password string `json:"password" envconfig:"optional,default=changeme"`
}
// Configuration is the main configuration object
var Configuration struct {
DataFolder string `json:"data_folder" envconfig:"optional,default=/etc/caddy/"`
LogFolder string `json:"log_folder" envconfig:"optional,default=/var/log/caddy"`
CaddyFile string `json:"caddy_file" envconfig:"optional,default=/etc/caddy/Caddyfile"`
Log log `json:"log"`
DataFolder string `json:"data_folder" envconfig:"optional,default=/etc/caddy/"`
LogFolder string `json:"log_folder" envconfig:"optional,default=/var/log/caddy"`
CaddyFile string `json:"caddy_file" envconfig:"optional,default=/etc/caddy/Caddyfile"`
FrontendDir string `json:"frontend_dir" envconfig:"optional"`
Caddy caddy `json:"caddy"`
Auth auth `json:"auth"`
Admin admin `json:"admin"`
Log log `json:"log"`
}

View File

@@ -4,7 +4,7 @@ import "gorm.io/gorm"
type Host struct {
gorm.Model
Domains string `json:"domains" validate:"required,fqdn|hostname_port"`
Domains string `json:"domains" validate:"required,domains"`
Matcher string `json:"matcher"`
Upstreams []Upstream
}
@@ -19,5 +19,9 @@ type User struct {
gorm.Model
Name string `validate:"required"`
Email string `gorm:"unique" validate:"required,email"`
Secret string
Secret string `json:"secret,omitempty"`
// Provider is the auth source for this user: "local" or "oidc".
Provider string `json:"provider" gorm:"default:local"`
// Subject is the OIDC subject identifier (sub claim), empty for local users.
Subject string `json:"subject,omitempty" gorm:"index"`
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/Pacerino/CaddyProxyManager/internal/config"
"github.com/Pacerino/CaddyProxyManager/internal/logger"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -17,6 +18,36 @@ func NewDB() {
logger.Info("Creating new DB instance")
dbInstance = SqliteDB()
dbInstance.AutoMigrate(&Host{}, &Upstream{}, &User{})
seedAdmin()
}
// seedAdmin creates a default admin user the first time CPM starts with an
// empty database. The credentials can be overridden via CPM_ADMIN_EMAIL and
// CPM_ADMIN_PASSWORD. This makes initial login possible without a separate
// setup step.
func seedAdmin() {
if config.Configuration.Auth.Mode == "oidc" {
return
}
var count int64
dbInstance.Model(&User{}).Count(&count)
if count > 0 {
return
}
email := config.Configuration.Admin.Email
password := config.Configuration.Admin.Password
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logger.Error("AdminSeedError", err)
return
}
user := User{Name: "Admin", Email: email, Secret: string(hash), Provider: "local"}
if err := dbInstance.Create(&user).Error; err != nil {
logger.Error("AdminSeedError", err)
return
}
logger.Info("Seeded default admin user: %s", email)
}
// GetInstance returns an existing or new instance

30
docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/sh
set -e
# Start Caddy in the background with just its admin API enabled. CPM (in the
# default "api" mode) then manages the HTTP config through that admin API.
# Mount a custom config at /data/caddy.json to override this behaviour.
CADDY_CONFIG="${CADDY_CONFIG:-/data/caddy.json}"
mkdir -p "$(dirname "$CADDY_CONFIG")"
if [ ! -f "$CADDY_CONFIG" ]; then
cat > "$CADDY_CONFIG" <<'JSON'
{
"admin": { "listen": "localhost:2019" },
"apps": { "http": { "servers": {} } }
}
JSON
fi
echo "Starting Caddy with admin API…"
caddy run --config "$CADDY_CONFIG" &
CADDY_PID=$!
# Stop Caddy when CPM exits.
trap 'kill "$CADDY_PID" 2>/dev/null || true' EXIT INT TERM
# Give the admin API a moment to come up.
sleep 1
echo "Starting Caddy Proxy Manager…"
exec /usr/local/bin/cpm

21
frontend/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Caddy Proxy Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

31444
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,42 @@
{
"name": "frontend",
"version": "0.1.0",
"name": "caddyproxymanager-frontend",
"private": true,
"dependencies": {
"@hookform/resolvers": "^2.9.8",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.11.59",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"axios": "^0.27.2",
"flowbite": "^1.5.3",
"flowbite-react": "^0.1.11",
"joi": "^17.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.36.0",
"react-router-dom": "^6.4.0",
"react-scripts": "5.0.1",
"typescript": "^4.8.3",
"web-vitals": "^2.1.4"
},
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc -b --noEmit"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-slot": "^1.1.1",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.28.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tw-animate-css": "^1.2.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/joi": "^17.2.3",
"autoprefixer": "^10.4.11",
"postcss": "^8.4.16",
"tailwindcss": "^3.1.8"
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -1,137 +1,35 @@
import Layout from "./components/Layout";
import {
Routes,
Route,
useNavigate,
useLocation,
Navigate,
} from "react-router-dom";
import React from "react";
import { localAuthProvider } from "./auth";
import { TextInput, Button, /* Alert */ } from "flowbite-react";
import { useForm, SubmitHandler } from "react-hook-form";
import { Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "@/lib/auth";
import { Layout } from "@/components/Layout";
import { LoginPage } from "@/pages/Login";
import { OIDCCallbackPage } from "@/pages/OIDCCallback";
import { HostsPage } from "@/pages/Hosts";
import type { ReactNode } from "react";
// Pages
import HostsPage from "./pages/Hosts";
function RequireAuth({ children }: { children: ReactNode }) {
const { token } = useAuth();
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function App() {
return (
<AuthProvider>
<Routes>
<Route element={<Layout />}>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<HostsPage />
</RequireAuth>
}
/>
</Route>
</Routes>
</AuthProvider>
);
}
interface AuthContextType {
user: any;
signin: (mail: string, password: string, callback: VoidFunction) => void;
signout: (callback: VoidFunction) => void;
}
let AuthContext = React.createContext<AuthContextType>(null!);
function AuthProvider({ children }: { children: React.ReactNode }) {
let [user, setUser] = React.useState<any>(null);
let signin = (email: string, password: string, callback: VoidFunction) => {
return localAuthProvider.signin(email, password, () => {
setUser(email);
callback();
});
};
let signout = (callback: VoidFunction) => {
return localAuthProvider.signout(() => {
setUser(null);
callback();
});
};
let value = { user, signin, signout };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
return React.useContext(AuthContext);
}
function RequireAuth({ children }: { children: JSX.Element }) {
let auth = useAuth();
let location = useLocation();
if (!auth.user) {
// Redirect them to the /login page, but save the current location they were
// trying to go to when they were redirected. This allows us to send them
// along to that page after they login, which is a nicer user experience
// than dropping them off on the home page.
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
interface FormValues {
email: string;
password: string;
}
function LoginPage() {
let navigate = useNavigate();
let location = useLocation();
let from = location.state?.from?.pathname || "/";
let auth = useAuth();
const { register, handleSubmit } = useForm<FormValues>();
const performLogin: SubmitHandler<FormValues> = async (data) => {
auth.signin(data.email, data.password, () => {
navigate(from, { replace: true });
})
};
return (
<form onSubmit={handleSubmit(performLogin)}>
<div className="flex justify-center">
<div className="px-8 pt-6 pb-8 mb-4 flex flex-col w-1/4">
<h1 className="text-white text-4xl pb-8">Please login</h1>
{/* {error && (
<div className="pb-8">
<Alert color="failure">{error}</Alert>
</div>
)} */}
<div className="mb-4">
<TextInput
type="email"
placeholder="name@caddyproxymanager.com"
required={true}
{...register("email", { required: true })}
/>
</div>
<div className="mb-6">
<TextInput
type="password"
required={true}
{...register("password", { required: true })}
/>
</div>
<div className="flex items-center justify-between">
<Button type="submit">Login</Button>
</div>
</div>
</div>
</form>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/login/callback" element={<OIDCCallbackPage />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/" element={<HostsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -1,23 +0,0 @@
import http, { RequestResponse} from "./utils/axios"
const localAuthProvider = {
isAuthenticated: false,
signin(email: string, password: string, callback: VoidFunction) {
http
.post<RequestResponse>("/users/login", {
email: email,
secret: password,
})
.then(result => {
localStorage.setItem("token", result.data.result.token);
callback();
})
},
signout(callback: VoidFunction) {
localAuthProvider.isAuthenticated = false;
localStorage.setItem("token", "");
callback();
},
};
export { localAuthProvider };

View File

@@ -0,0 +1,189 @@
import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { Host } from "@/lib/types";
const schema = z.object({
matcher: z.string().optional(),
domains: z.array(z.object({ value: z.string().min(1) })).min(1),
upstreams: z.array(z.object({ backend: z.string().min(1) })).min(1),
});
export type HostFormValues = z.infer<typeof schema>;
export interface HostPayload {
domains: string;
matcher: string;
upstreams: { backend: string }[];
}
interface HostDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
host: Host | null;
submitting: boolean;
onSubmit: (payload: HostPayload) => void;
}
function toFormValues(host: Host | null): HostFormValues {
if (!host) {
return {
matcher: "",
domains: [{ value: "" }],
upstreams: [{ backend: "" }],
};
}
return {
matcher: host.matcher ?? "",
domains: host.domains
? host.domains.split(/[\s,]+/).filter(Boolean).map((value) => ({ value }))
: [{ value: "" }],
upstreams:
host.Upstreams?.length > 0
? host.Upstreams.map((u) => ({ backend: u.backend }))
: [{ backend: "" }],
};
}
export function HostDialog({
open,
onOpenChange,
host,
submitting,
onSubmit,
}: HostDialogProps) {
const {
register,
handleSubmit,
control,
reset,
formState: { errors },
} = useForm<HostFormValues>({
resolver: zodResolver(schema),
defaultValues: toFormValues(host),
});
useEffect(() => {
reset(toFormValues(host));
}, [host, open, reset]);
const domains = useFieldArray({ control, name: "domains" });
const upstreams = useFieldArray({ control, name: "upstreams" });
const submit = handleSubmit((data) => {
onSubmit({
matcher: data.matcher ?? "",
domains: data.domains.map((d) => d.value).join(" "),
upstreams: data.upstreams,
});
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{host ? "Edit host" : "Add host"}</DialogTitle>
<DialogDescription>
Configure domains, an optional matcher and upstream backends.
</DialogDescription>
</DialogHeader>
<form className="space-y-5" onSubmit={submit}>
<div className="space-y-2">
<Label>Domains</Label>
{domains.fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<Input
placeholder="example.com"
{...register(`domains.${index}.value` as const)}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={domains.fields.length === 1}
onClick={() => domains.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
{errors.domains && (
<p className="text-sm text-destructive">
At least one valid domain is required
</p>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => domains.append({ value: "" })}
>
<Plus className="size-4" />
Add domain
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="matcher">Matcher (optional)</Label>
<Input id="matcher" placeholder="/api/*" {...register("matcher")} />
</div>
<div className="space-y-2">
<Label>Upstreams</Label>
{upstreams.fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<Input
placeholder="127.0.0.1:8080"
{...register(`upstreams.${index}.backend` as const)}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={upstreams.fields.length === 1}
onClick={() => upstreams.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
{errors.upstreams && (
<p className="text-sm text-destructive">
At least one valid upstream is required
</p>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => upstreams.append({ backend: "" })}
>
<Plus className="size-4" />
Add upstream
</Button>
</div>
<DialogFooter>
<Button type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,43 +1,36 @@
import { Navbar, Button } from "flowbite-react";
import { Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
function Layout() {
const auth = useAuth();
import { LogOut, Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth";
export function Layout() {
const { logout } = useAuth();
const navigate = useNavigate();
return (
<div>
<Navbar fluid={true} rounded={false}>
<Navbar.Brand href="https://flowbite.com/">
<img
src="https://flowbite.com/docs/images/logo.svg"
className="mr-3 h-6 sm:h-9"
alt="Flowbite Logo"
/>
<span className="self-center whitespace-nowrap text-xl font-semibold dark:text-white">
Caddy Proxy Manager
</span>
</Navbar.Brand>
{auth.user && (
<div className="flex md:order-2">
<Button onClick={() => auth.signout(() => navigate("/"))}>
Logout
</Button>
<Navbar.Toggle />
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-2">
<Server className="size-5" />
<span className="text-lg font-semibold">Caddy Proxy Manager</span>
</div>
)}
<Navbar.Collapse>
{/* <Navbar.Link href="/home">Home</Navbar.Link>
<Navbar.Link href="/hosts">Hosts</Navbar.Link> */}
</Navbar.Collapse>
</Navbar>
<div className="container mx-auto py-4">
<Button
variant="ghost"
size="sm"
onClick={() => {
logout();
navigate("/login");
}}
>
<LogOut className="size-4" />
Logout
</Button>
</div>
</header>
<main className="mx-auto max-w-6xl px-6 py-8">
<Outlet />
</div>
</main>
</div>
);
}
export default Layout;

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
data-slot="input"
ref={ref}
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,37 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,3 +1,82 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: ui-sans-serif, system-ui, sans-serif;
}

View File

@@ -1,25 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { BrowserRouter } from "react-router-dom";
import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
document.body.classList.add('bg-slate-900');
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

38
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,38 @@
import axios from "axios";
// Same-origin in production (served by the Go binary); the Vite dev server
// proxies /api to the backend.
export const api = axios.create({
baseURL: "/api",
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);
// The backend wraps payloads as { result, error }.
export interface ApiEnvelope<T> {
result: T;
error?: { code: number; message: string };
}
export function unwrap<T>(data: ApiEnvelope<T>): T {
return data.result;
}

70
frontend/src/lib/auth.tsx Normal file
View File

@@ -0,0 +1,70 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api, unwrap, type ApiEnvelope } from "@/lib/api";
import type { AuthConfig } from "@/lib/types";
interface AuthContextValue {
token: string | null;
authConfig: AuthConfig | null;
loadingConfig: boolean;
loginLocal: (email: string, password: string) => Promise<void>;
setToken: (token: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue>(null!);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setTokenState] = useState<string | null>(() =>
localStorage.getItem("token")
);
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
const [loadingConfig, setLoadingConfig] = useState(true);
useEffect(() => {
api
.get<ApiEnvelope<AuthConfig>>("/auth/config")
.then((res) => setAuthConfig(unwrap(res.data)))
.catch(() => setAuthConfig({ mode: "local" }))
.finally(() => setLoadingConfig(false));
}, []);
const setToken = useCallback((value: string) => {
localStorage.setItem("token", value);
setTokenState(value);
}, []);
const loginLocal = useCallback(
async (email: string, password: string) => {
const res = await api.post<ApiEnvelope<{ token: string }>>(
"/users/login",
{ email, secret: password }
);
setToken(unwrap(res.data).token);
},
[setToken]
);
const logout = useCallback(() => {
localStorage.removeItem("token");
setTokenState(null);
}, []);
const value = useMemo(
() => ({ token, authConfig, loadingConfig, loginLocal, setToken, logout }),
[token, authConfig, loadingConfig, loginLocal, setToken, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
return useContext(AuthContext);
}

20
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface Upstream {
ID?: number;
hostId?: number;
backend: string;
}
export interface Host {
ID: number;
CreatedAt: string;
UpdatedAt: string;
domains: string;
matcher: string;
Upstreams: Upstream[];
}
export type AuthMode = "local" | "oidc";
export interface AuthConfig {
mode: AuthMode;
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

18
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
import { AuthProvider } from "@/lib/auth";
import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<Toaster />
</AuthProvider>
</BrowserRouter>
</StrictMode>
);

View File

@@ -1,300 +1,170 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Table,
Button,
Modal,
TextInput,
Label,
Spinner,
} from "flowbite-react";
import { useForm, useFieldArray, SubmitHandler } from "react-hook-form";
import { joiResolver } from "@hookform/resolvers/joi";
import Joi from "joi";
import { HiAdjustments, HiTrash, HiDocumentAdd } from "react-icons/hi";
import React from "react";
import http, { RequestResponse } from "../utils/axios";
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api, unwrap, type ApiEnvelope } from "@/lib/api";
import type { Host } from "@/lib/types";
import { HostDialog, type HostPayload } from "@/components/HostDialog";
type Domain = {
fqdn: string;
};
export function HostsPage() {
const [hosts, setHosts] = useState<Host[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Host | null>(null);
const [submitting, setSubmitting] = useState(false);
type UpstreamForm = {
backend: string;
};
type FormValues = {
domains: Domain[];
upstreams: UpstreamForm[];
matcher: string | undefined;
};
interface Upstream {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt?: any;
hostId: number;
backend: string;
}
interface Hosts {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt?: any;
domains: string;
matcher: string;
Upstreams: Upstream[];
}
interface RootObject {
result: Hosts[];
}
function HostsPage() {
const [modal, setModal] = React.useState(false);
const [hostData, setHostData] = React.useState<RootObject>();
const [loading, setLoading] = React.useState(false);
const schema = Joi.object<FormValues>({
matcher: Joi.any().optional(),
upstreams: Joi.array().items(
Joi.object().keys({
backend: Joi.string().required(),
})
),
domains: Joi.array().items(
Joi.object().keys({
fqdn: Joi.string().required(),
})
),
});
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm({
resolver: joiResolver(schema),
defaultValues: {
matcher: "",
domains: [{ fqdn: "" }],
upstreams: [{ backend: "" }],
},
});
const {
fields: upstreamFields,
append: upstreamAppend,
remove: upstreamRemove,
} = useFieldArray({
control,
name: "upstreams",
});
const {
fields: domainFields,
append: domainAppend,
remove: domainRemove,
} = useFieldArray({
control,
name: "domains",
});
React.useEffect(() => {
getHosts();
const loadHosts = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ApiEnvelope<Host[]>>("/hosts");
setHosts(unwrap(res.data) ?? []);
} catch {
toast.error("Failed to load hosts");
} finally {
setLoading(false);
}
}, []);
const getHosts = async () => {
return await http.get<RequestResponse>(`/hosts`)
.then((res) => {
setHostData(res.data);
});
useEffect(() => {
loadHosts();
}, [loadHosts]);
const openCreate = () => {
setEditing(null);
setDialogOpen(true);
};
const createHost: SubmitHandler<FormValues> = async (data) => {
const jsonBody = {
matcher: data.matcher,
domains: data.domains
.map((e) => {
return e.fqdn;
})
.join(","),
upstreams: data.upstreams,
};
setLoading(true);
await http.post(`hosts`, jsonBody);
setLoading(false);
setModal(false);
getHosts();
const openEdit = (host: Host) => {
setEditing(host);
setDialogOpen(true);
};
const deleteHost = async (hostID: number) => {
await http.delete(`/hosts/${hostID}`);
getHosts();
const saveHost = async (payload: HostPayload) => {
setSubmitting(true);
try {
if (editing) {
await api.put("/hosts", {
...editing,
domains: payload.domains,
matcher: payload.matcher,
Upstreams: payload.upstreams.map((u) => ({ backend: u.backend })),
});
toast.success("Host updated");
} else {
await api.post("/hosts", {
domains: payload.domains,
matcher: payload.matcher,
Upstreams: payload.upstreams,
});
toast.success("Host created");
}
setDialogOpen(false);
loadHosts();
} catch {
toast.error("Failed to save host");
} finally {
setSubmitting(false);
}
};
const deleteHost = async (id: number) => {
try {
await api.delete(`/hosts/${id}`);
toast.success("Host deleted");
loadHosts();
} catch {
toast.error("Failed to delete host");
}
};
return (
<>
<div className="pb-4 bg-white dark:bg-gray-900">
<Button size="xs" color="gray" onClick={() => setModal(true)}>
<HiDocumentAdd className="mr-3 h-4 w-4" /> Add Host
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Hosts</h1>
<p className="text-sm text-muted-foreground">
Manage your reverse proxy hosts.
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
Add host
</Button>
</div>
<Table>
<Table.Head>
<Table.HeadCell>ID</Table.HeadCell>
<Table.HeadCell>Created</Table.HeadCell>
<Table.HeadCell>Updated</Table.HeadCell>
<Table.HeadCell>Domains</Table.HeadCell>
<Table.HeadCell>Matcher</Table.HeadCell>
<Table.HeadCell>Upstreams</Table.HeadCell>
<Table.HeadCell>
<span className="sr-only">Edit</span>
</Table.HeadCell>
</Table.Head>
<Table.Body className="divide-y">
{hostData?.result.map((entry, i) => {
return (
<Table.Row
key={entry.ID}
className="bg-white dark:border-gray-700 dark:bg-gray-800"
>
<Table.Cell>{entry.ID}</Table.Cell>
<Table.Cell>{entry.CreatedAt}</Table.Cell>
<Table.Cell>{entry.UpdatedAt}</Table.Cell>
<Table.Cell>{entry.domains}</Table.Cell>
<Table.Cell>{entry.matcher}</Table.Cell>
<Table.Cell>{entry.Upstreams[0].backend}</Table.Cell>
<Table.Cell>
<Button.Group>
<Button size="xs" color="gray">
<HiAdjustments className="mr-3 h-4 w-4" /> Edit
</Button>
<Button
size="xs"
color="gray"
onClick={() => deleteHost(entry.ID)}
>
<HiTrash className="mr-3 h-4 w-4" /> Delete
</Button>
</Button.Group>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
<Modal show={modal} size="xl" onClose={() => setModal(false)}>
<Modal.Header>Add a new Host</Modal.Header>
<Modal.Body>
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(createHost)}
>
<div className="space-y-2">
<div className="mb-2 block">
<Label htmlFor="domain" value="Domain" />
</div>
<ul className="space-y-2">
{domainFields.map((field, index) => {
return (
<li key={field.id}>
<TextInput
type="text"
placeholder="example.com"
id={`domains.${index}.fqdn`}
key={field.id}
{...register(`domains.${index}.fqdn` as const)}
addon={
index > 0 && (
<Button size="xs" color="gray" onClick={() => domainRemove(index)}>
Delete
</Button>
)
}
helperText={
errors.domains?.[index] && (
<span>Please enter a valid FQDN</span>
)
}
/>
</li>
);
})}
</ul>
<div className="mb-2 block">
<Label value="Matcher" />
</div>
<TextInput
type="text"
id="matcher"
placeholder="/api/*"
{...register("matcher", { required: false })}
/>
<div className="mb-2 block">
<Label value="Upstreams" />
</div>
<ul className="space-y-2">
{upstreamFields.map((field, index) => {
return (
<li key={field.id}>
<TextInput
type="text"
placeholder="127.0.0.1:8080"
id={`upstreams.${index}.backend`}
key={field.id}
{...register(`upstreams.${index}.backend` as const)}
addon={
index > 0 && (
<Button size="xs" color="gray" onClick={() => upstreamRemove(index)}>
Delete
</Button>
)
}
helperText={
errors.upstreams?.[index] && (
<span>Please enter a valid IP Address</span>
)
}
/>
</li>
);
})}
</ul>
</div>
<Button.Group>
{loading ? (
<Button disabled={true}>
<div className="mr-3">
<Spinner size="sm" light={true} />
</div>
Loading ...
</Button>
) : (
<Button type="submit">Save</Button>
)}
<div className="rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">ID</TableHead>
<TableHead>Domains</TableHead>
<TableHead>Matcher</TableHead>
<TableHead>Upstreams</TableHead>
<TableHead className="w-28 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Loading
</TableCell>
</TableRow>
) : hosts.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No hosts yet.
</TableCell>
</TableRow>
) : (
hosts.map((host) => (
<TableRow key={host.ID}>
<TableCell>{host.ID}</TableCell>
<TableCell className="font-medium">{host.domains}</TableCell>
<TableCell>{host.matcher || "—"}</TableCell>
<TableCell>
{host.Upstreams?.map((u) => u.backend).join(", ") || "—"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(host)}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteHost(host.ID)}
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Button
disabled={loading}
type="button"
onClick={() => domainAppend({ fqdn: "" })}
>
Add Domain
</Button>
<Button
disabled={loading}
type="button"
onClick={() => upstreamAppend({ backend: "" })}
>
Add Upstream
</Button>
</Button.Group>
</form>
</Modal.Body>
</Modal>
</>
<HostDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
host={editing}
submitting={submitting}
onSubmit={saveHost}
/>
</div>
);
}
export default HostsPage;

View File

@@ -0,0 +1,115 @@
import { useState } from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { KeyRound, LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAuth } from "@/lib/auth";
const schema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
type FormValues = z.infer<typeof schema>;
export function LoginPage() {
const { token, authConfig, loadingConfig, loginLocal } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
const [submitting, setSubmitting] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
if (token) {
return <Navigate to="/" replace />;
}
const error = params.get("error");
if (error) {
toast.error(error);
}
const onSubmit = handleSubmit(async (data) => {
setSubmitting(true);
try {
await loginLocal(data.email, data.password);
navigate("/");
} catch {
toast.error("Login incorrect");
} finally {
setSubmitting(false);
}
});
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>Access your Caddy Proxy Manager</CardDescription>
</CardHeader>
<CardContent>
{loadingConfig ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : authConfig?.mode === "oidc" ? (
<Button
className="w-full"
onClick={() => {
window.location.href = "/api/auth/oidc/login";
}}
>
<KeyRound className="size-4" />
Login with SSO
</Button>
) : (
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@example.com"
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">
Enter a valid email
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" {...register("password")} />
{errors.password && (
<p className="text-sm text-destructive">
Password is required
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={submitting}>
<LogIn className="size-4" />
{submitting ? "Signing in…" : "Sign in"}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/lib/auth";
// The backend redirects here with the CPM token in the URL fragment
// (#token=...). We store it and move into the app.
export function OIDCCallbackPage() {
const { setToken } = useAuth();
const navigate = useNavigate();
useEffect(() => {
const hash = window.location.hash.replace(/^#/, "");
const token = new URLSearchParams(hash).get("token");
if (token) {
setToken(token);
navigate("/", { replace: true });
} else {
navigate("/login?error=login%20failed", { replace: true });
}
}, [setToken, navigate]);
return (
<div className="flex min-h-screen items-center justify-center text-muted-foreground">
Signing you in
</div>
);
}

View File

@@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@@ -1,37 +0,0 @@
import axios, { AxiosRequestConfig } from "axios";
export interface RequestResponse {
result: any;
error: Error;
}
interface Error {
code: number;
message: string;
}
axios.interceptors.request.use((config: AxiosRequestConfig) => {
if (!config) {
config = {};
}
if (!config.headers) {
config.headers = {};
}
const token = localStorage.getItem("token");
if (token) {
config.headers.authorization = `Bearer ${token}`;
}
config.baseURL = "http://localhost:3001/api/";
return config;
});
const methods = {
get: axios.get,
post: axios.post,
put: axios.put,
delete: axios.delete,
patch: axios.patch,
};
export default methods;

View File

@@ -1,15 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
'node_modules/flowbite-react/**/*.{js,jsx,ts,tsx}',
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
darkMode: 'media',
plugins: [
require('flowbite/plugin')
]
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,26 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
// Build straight into the Go embed directory so the binary picks it up.
outDir: "../backend/embed/assets",
emptyOutDir: true,
},
server: {
proxy: {
// Proxy API calls to the Go backend during development.
"/api": "http://localhost:3001",
},
},
});

File diff suppressed because it is too large Load Diff