mirror of
https://github.com/Pacerino/CaddyProxyManager.git
synced 2026-07-29 00:42:45 -04:00
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:
10
.dockerignore
Normal file
10
.dockerignore
Normal 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
118
.github/workflows/ci.yml
vendored
Normal 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
26
.gitignore
vendored
@@ -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
48
Dockerfile
Normal 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"]
|
||||
84
README.md
84
README.md
@@ -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!
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
207
backend/go.sum
207
backend/go.sum
@@ -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=
|
||||
|
||||
109
backend/internal/api/handler/auth.go
Normal file
109
backend/internal/api/handler/auth.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
39
backend/internal/api/handler/validator_test.go
Normal file
39
backend/internal/api/handler/validator_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
33
backend/internal/auth/bootstrap.go
Normal file
33
backend/internal/auth/bootstrap.go
Normal 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)
|
||||
}
|
||||
166
backend/internal/auth/oidc.go
Normal file
166
backend/internal/auth/oidc.go
Normal 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
|
||||
}
|
||||
54
backend/internal/auth/oidc_test.go
Normal file
54
backend/internal/auth/oidc_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
167
backend/internal/caddy/api.go
Normal file
167
backend/internal/caddy/api.go
Normal 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
|
||||
}
|
||||
164
backend/internal/caddy/api_test.go
Normal file
164
backend/internal/caddy/api_test.go
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
50
backend/internal/caddy/json.go
Normal file
50
backend/internal/caddy/json.go
Normal 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
|
||||
}
|
||||
67
backend/internal/caddy/json_test.go
Normal file
67
backend/internal/caddy/json_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
31
backend/internal/caddy/provider.go
Normal file
31
backend/internal/caddy/provider.go
Normal 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)
|
||||
}
|
||||
}
|
||||
54
backend/internal/caddy/provider_test.go
Normal file
54
backend/internal/caddy/provider_test.go
Normal 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"
|
||||
}
|
||||
}
|
||||
103
backend/internal/caddy/reload.go
Normal file
103
backend/internal/caddy/reload.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
30
docker/entrypoint.sh
Executable 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
21
frontend/components.json
Normal 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
12
frontend/index.html
Normal 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
31444
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
189
frontend/src/components/HostDialog.tsx
Normal file
189
frontend/src/components/HostDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
64
frontend/src/components/ui/button.tsx
Normal file
64
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
25
frontend/src/components/ui/input.tsx
Normal file
25
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
22
frontend/src/components/ui/label.tsx
Normal file
22
frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
37
frontend/src/components/ui/sonner.tsx
Normal file
37
frontend/src/components/ui/sonner.tsx
Normal 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 }
|
||||
114
frontend/src/components/ui/table.tsx
Normal file
114
frontend/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
38
frontend/src/lib/api.ts
Normal 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
70
frontend/src/lib/auth.tsx
Normal 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
20
frontend/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
18
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
115
frontend/src/pages/Login.tsx
Normal file
115
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/pages/OIDCCallback.tsx
Normal file
27
frontend/src/pages/OIDCCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/react-app-env.d.ts
vendored
1
frontend/src/react-app-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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')
|
||||
]
|
||||
}
|
||||
27
frontend/tsconfig.app.json
Normal file
27
frontend/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
frontend/tsconfig.node.json
Normal file
15
frontend/tsconfig.node.json
Normal 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
25
frontend/vite.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
9729
frontend/yarn.lock
9729
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user