Fix setup.sh: add -k flag, NSS import for Fedora, correct trust flags, LibreWolf docs
This commit is contained in:
commit
5d417c20e1
19
.env.example
Normal file
19
.env.example
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# CertAuth Environment Variables
|
||||||
|
# Copy this to .env and fill in your real values
|
||||||
|
|
||||||
|
# Admin web login password
|
||||||
|
ADMIN_PASS=your-secure-admin-password
|
||||||
|
|
||||||
|
# YubiKey 1 (Root CA) credentials
|
||||||
|
YK_ROOT_PIN=your-yk1-pin
|
||||||
|
YK_ROOT_PUK=your-yk1-puk
|
||||||
|
|
||||||
|
# YubiKey 2 (Intermediate CA) credentials
|
||||||
|
YK_INT_PIN=your-yk2-pin
|
||||||
|
YK_INT_PUK=your-yk2-puk
|
||||||
|
|
||||||
|
# JWT signing secret (generate with: python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
JWT_SECRET=your-64-char-hex-secret
|
||||||
|
|
||||||
|
# PFX download password
|
||||||
|
PFX_PASS=certauth
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Environment variables containing real secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local overrides
|
||||||
|
*.local
|
||||||
17
Caddyfile
Normal file
17
Caddyfile
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
:443 {
|
||||||
|
encode gzip
|
||||||
|
tls /etc/ssl/certauth/tls.pem /etc/ssl/certauth/tls.key
|
||||||
|
|
||||||
|
reverse_proxy 127.0.0.1:8000 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Real-IP {remote}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
redir https://{host}{uri} permanent
|
||||||
|
}
|
||||||
348
PLAN.md
Normal file
348
PLAN.md
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
# Certificate Authority Infrastructure Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Build a secure, self-contained Certificate Authority (CA) infrastructure on an isolated Ubuntu machine (`192.168.8.248`) using two YubiKeys for hardware-secured key storage, with a web-based Key Vault UI for certificate management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ certauth Machine (192.168.8.248) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ YubiKey #1 │ │ YubiKey #2 │ │
|
||||||
|
│ │ Root CA Key │ │ Intermediate │ │
|
||||||
|
│ │ (PIN+Touch) │ │ CA Key │ │
|
||||||
|
│ └──────┬───────┘ │ (PIN+Touch) │ │
|
||||||
|
│ │ └──────┬───────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ Root Certificate Intermediate Cert │
|
||||||
|
│ (signed on YK1) (signed by Root) │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────▼─────────┐ │
|
||||||
|
│ │ Certificate API │ │
|
||||||
|
│ │ + Key Vault UI │ │
|
||||||
|
│ └─────────┬─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────▼─────────┐ │
|
||||||
|
│ │ Certificate DB │ │
|
||||||
|
│ │ (SQLite/Postgres) │ │
|
||||||
|
│ └───────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Firewall: Only local network (192.168.8.0/24) │
|
||||||
|
│ No outbound internet except Ubuntu package updates │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Machine Hardening
|
||||||
|
|
||||||
|
### 1.1 Base System Configuration
|
||||||
|
- Fresh Ubuntu Server installation verification
|
||||||
|
- Create dedicated `certauth` user with sudo privileges
|
||||||
|
- Disable root login, enforce key-based SSH
|
||||||
|
- Configure SSH to only accept connections from local network (192.168.8.0/24)
|
||||||
|
- Set up UFW firewall:
|
||||||
|
- Allow SSH (port 22) from 192.168.8.0/24 only
|
||||||
|
- Allow HTTPS (port 443) for Key Vault UI from 192.168.8.0/24 only
|
||||||
|
- Allow HTTP (port 80) for redirect from 192.168.8.0/24 only
|
||||||
|
- Deny all other inbound traffic
|
||||||
|
- Deny all outbound traffic except:
|
||||||
|
- Ubuntu package repositories (archive.ubuntu.com, security.ubuntu.com)
|
||||||
|
- DNS (port 53)
|
||||||
|
- NTP (port 123)
|
||||||
|
- Local network traffic (192.168.8.0/24)
|
||||||
|
|
||||||
|
### 1.2 System Hardening
|
||||||
|
- Automatic security updates enabled
|
||||||
|
- Fail2ban for SSH protection
|
||||||
|
- Auditd for security auditing
|
||||||
|
- Remove unnecessary packages and services
|
||||||
|
- ConfigureAppArmor profiles
|
||||||
|
- Set up encrypted swap
|
||||||
|
- Configure secure kernel parameters (sysctl)
|
||||||
|
|
||||||
|
### 1.3 YubiKey Preparation
|
||||||
|
- Install `yubikey-manager`, `pcscd`, `opensc`, `gnutls-bin`
|
||||||
|
- Verify YubiKey connectivity and PIV applet
|
||||||
|
- Test both YubiKeys are recognized
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: YubiKey #1 - Root CA
|
||||||
|
|
||||||
|
### 2.1 YubiKey Configuration
|
||||||
|
- Set PIV PIN (strong, 9-16 chars)
|
||||||
|
- Set PIV PUK (for PIN reset)
|
||||||
|
- Set management key
|
||||||
|
- Configure touch policy to `callback` or `fixed-on` for the signing key slot
|
||||||
|
- Generate RSA 4096-bit (or EC P-384) key pair **on the YubiKey** (never leaves device)
|
||||||
|
- Key stored in PIV slot `9c` (signing)
|
||||||
|
|
||||||
|
### 2.2 Root Certificate Generation
|
||||||
|
- Generate self-signed Root CA certificate on YubiKey
|
||||||
|
- Validity: 20-30 years
|
||||||
|
- Key usage: Certificate Signing, CRL Signing
|
||||||
|
- Basic constraints: CA:TRUE, pathlen:1
|
||||||
|
- Subject: `CN=certauth Root CA, O=Home, C=US` (customizable)
|
||||||
|
- Store Root CA certificate in `/etc/ssl/ca/root/`
|
||||||
|
|
||||||
|
### 2.3 Security
|
||||||
|
- Root CA certificate is public and stored on disk
|
||||||
|
- Root private key **never** leaves YubiKey #1
|
||||||
|
- YubiKey #1 can be physically removed and stored offline when not in use
|
||||||
|
- All signing operations require PIN + physical touch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: YubiKey #2 - Intermediate CA
|
||||||
|
|
||||||
|
### 3.1 YubiKey Configuration
|
||||||
|
- Set PIV PIN (strong, 9-16 chars, different from YK1)
|
||||||
|
- Set PIV PUK
|
||||||
|
- Set management key
|
||||||
|
- Configure touch policy to `callback` or `fixed-on` for signing key slot
|
||||||
|
- Generate RSA 4096-bit (or EC P-384) key pair **on the YubiKey**
|
||||||
|
- Key stored in PIV slot `9c` (signing)
|
||||||
|
|
||||||
|
### 3.2 Intermediate Certificate Generation
|
||||||
|
- Generate CSR on YubiKey #2
|
||||||
|
- Sign CSR using YubiKey #1 (Root CA) — requires PIN + touch on YK1
|
||||||
|
- Validity: 10-15 years
|
||||||
|
- Key usage: Certificate Signing, CRL Signing
|
||||||
|
- Basic constraints: CA:TRUE, pathlen:0
|
||||||
|
- Subject: `CN=certauth Intermediate CA, O=Home, C=US`
|
||||||
|
- Store Intermediate CA certificate in `/etc/ssl/ca/intermediate/`
|
||||||
|
|
||||||
|
### 3.3 Security
|
||||||
|
- Intermediate private key **never** leaves YubiKey #2
|
||||||
|
- All signing operations require PIN + physical touch
|
||||||
|
- Certificate chain: Root CA → Intermediate CA → Leaf Certificates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Certificate Management API & Key Vault UI
|
||||||
|
|
||||||
|
### 4.1 Technology Stack
|
||||||
|
- **Backend**: Python FastAPI (lightweight, async, good OpenAPI support)
|
||||||
|
- **Database**: SQLite (simple, file-based, sufficient for this scale)
|
||||||
|
- **Frontend**: HTMX + Tailwind CSS (minimal JS, server-rendered, fast)
|
||||||
|
- **Reverse Proxy**: Caddy (automatic HTTPS, simple config)
|
||||||
|
- **Process Manager**: systemd
|
||||||
|
|
||||||
|
### 4.2 API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
Authentication:
|
||||||
|
POST /api/auth/login - Admin login (username + password + TOTP)
|
||||||
|
POST /api/auth/logout - Logout
|
||||||
|
GET /api/auth/verify - Verify API key
|
||||||
|
|
||||||
|
Domain Management:
|
||||||
|
POST /api/domains - Register a new domain (e.g., *.example.com)
|
||||||
|
GET /api/domains - List all registered domains
|
||||||
|
GET /api/domains/{id} - Get domain details
|
||||||
|
DELETE /api/domains/{id} - Revoke domain registration
|
||||||
|
|
||||||
|
Certificate Management:
|
||||||
|
POST /api/certs/request - Request a new certificate for a domain
|
||||||
|
GET /api/certs - List all certificates
|
||||||
|
GET /api/certs/{id} - Get certificate details
|
||||||
|
GET /api/certs/{id}/download - Download certificate + chain
|
||||||
|
POST /api/certs/{id}/revoke - Revoke a certificate
|
||||||
|
POST /api/certs/{id}/renew - Renew a certificate
|
||||||
|
|
||||||
|
Health & Status:
|
||||||
|
GET /api/health - Health check
|
||||||
|
GET /api/yubikeys - YubiKey status (connected, slots)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Key Vault UI Features
|
||||||
|
- Dashboard showing certificate inventory, expiring certs, YubiKey status
|
||||||
|
- Domain registration form with validation
|
||||||
|
- Certificate request workflow:
|
||||||
|
1. Select registered domain
|
||||||
|
2. Specify SANs (Subject Alternative Names)
|
||||||
|
3. Choose validity period
|
||||||
|
4. Confirm request (triggers signing workflow)
|
||||||
|
- Certificate signing workflow:
|
||||||
|
1. System generates CSR locally
|
||||||
|
2. Admin inserts YubiKey #2, enters PIN, touches YubiKey
|
||||||
|
3. Certificate is signed and stored
|
||||||
|
4. Certificate + chain available for download
|
||||||
|
- Certificate download page with PEM files
|
||||||
|
- Audit log viewer
|
||||||
|
- Settings page for CA configuration
|
||||||
|
|
||||||
|
### 4.4 Signing Workflow (Human-in-the-Loop)
|
||||||
|
Since YubiKey requires physical touch + PIN, the signing process is semi-interactive:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Admin requests certificate via UI/API
|
||||||
|
2. System generates CSR and stores pending request
|
||||||
|
3. Admin runs: certauth sign --request <id>
|
||||||
|
- This prompts for YubiKey #2 PIN
|
||||||
|
- Admin touches YubiKey #2 when prompted
|
||||||
|
- Certificate is signed by Intermediate CA
|
||||||
|
4. Signed certificate stored in database
|
||||||
|
5. Status updated to "issued"
|
||||||
|
6. Certificate available for download
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative: Web-based signing using WebAuthn/CTAP2 (future enhancement)
|
||||||
|
|
||||||
|
### 4.5 API Key Authentication
|
||||||
|
- Admin generates API keys via UI
|
||||||
|
- API keys are scoped (read-only, sign, admin)
|
||||||
|
- Services authenticate with API key header: `X-API-Key: <key>`
|
||||||
|
- API keys stored hashed in database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Security Hardening
|
||||||
|
|
||||||
|
### 5.1 Application Security
|
||||||
|
- All API endpoints require authentication
|
||||||
|
- Rate limiting on API endpoints
|
||||||
|
- Input validation and sanitization
|
||||||
|
- SQL injection prevention (parameterized queries)
|
||||||
|
- CSRF protection for UI
|
||||||
|
- Content Security Policy headers
|
||||||
|
- Secure cookie flags (HttpOnly, Secure, SameSite)
|
||||||
|
|
||||||
|
### 5.2 Data Protection
|
||||||
|
- Database encrypted at rest (LUKS encrypted partition)
|
||||||
|
- Certificate files stored with restricted permissions (root:certauth, 0640)
|
||||||
|
- No secrets in plaintext logs
|
||||||
|
- API keys hashed (bcrypt)
|
||||||
|
- Passwords hashed (bcrypt)
|
||||||
|
|
||||||
|
### 5.3 Network Security
|
||||||
|
- Caddy configured with strong TLS settings
|
||||||
|
- Only binds to local network interfaces
|
||||||
|
- No services exposed to internet
|
||||||
|
- Outbound connections restricted by firewall
|
||||||
|
|
||||||
|
### 5.4 Operational Security
|
||||||
|
- Audit logging of all certificate operations
|
||||||
|
- Regular backup of certificates and configuration (encrypted)
|
||||||
|
- Monitoring for failed authentication attempts
|
||||||
|
- Log rotation and retention policy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Setup Script
|
||||||
|
|
||||||
|
### 6.1 Automated Setup Script (`setup-certauth.sh`)
|
||||||
|
A single script that can provision a fresh Ubuntu machine:
|
||||||
|
- Validates prerequisites (Ubuntu version, YubiKeys present)
|
||||||
|
- Runs all hardening steps
|
||||||
|
- Configures YubiKeys (interactive prompts for PINs)
|
||||||
|
- Generates CA certificates
|
||||||
|
- Installs and configures the Certificate API
|
||||||
|
- Sets up firewall rules
|
||||||
|
- Creates systemd services
|
||||||
|
- Outputs configuration summary and credentials
|
||||||
|
|
||||||
|
### 6.2 Configuration File (`certauth.conf`)
|
||||||
|
All configurable values in one file:
|
||||||
|
- CA subject information
|
||||||
|
- Certificate validity periods
|
||||||
|
- Network configuration
|
||||||
|
- Admin credentials
|
||||||
|
- YubiKey PIN policies
|
||||||
|
|
||||||
|
### 6.3 Documentation
|
||||||
|
- README with architecture overview
|
||||||
|
- OPERATIONS.md with daily usage instructions
|
||||||
|
- TROUBLESHOOTING.md with common issues
|
||||||
|
- BACKUP.md with backup and restore procedures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
certauth/
|
||||||
|
├── setup-certauth.sh # Main setup script
|
||||||
|
├── certauth.conf # Configuration file
|
||||||
|
├── README.md # Project documentation
|
||||||
|
├── OPERATIONS.md # Operations guide
|
||||||
|
├── scripts/
|
||||||
|
│ ├── harden-system.sh # System hardening
|
||||||
|
│ ├── configure-yubikey.sh # YubiKey setup
|
||||||
|
│ ├── generate-ca.sh # CA certificate generation
|
||||||
|
│ ├── install-api.sh # API installation
|
||||||
|
│ └── configure-firewall.sh # Firewall setup
|
||||||
|
├── api/
|
||||||
|
│ ├── main.py # FastAPI application
|
||||||
|
│ ├── models.py # Database models
|
||||||
|
│ ├── auth.py # Authentication
|
||||||
|
│ ├── signing.py # Certificate signing logic
|
||||||
|
│ ├── templates/ # HTML templates
|
||||||
|
│ ├── static/ # Static assets
|
||||||
|
│ └── requirements.txt # Python dependencies
|
||||||
|
├── systemd/
|
||||||
|
│ ├── certauth-api.service # API service unit
|
||||||
|
│ └── certauth-sign.timer # Signing timer unit
|
||||||
|
├── caddy/
|
||||||
|
│ └── Caddyfile # Caddy configuration
|
||||||
|
├── ssl/
|
||||||
|
│ └── openssl.cnf # OpenSSL configuration
|
||||||
|
└── tests/
|
||||||
|
└── test_api.py # API tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Plan Review** — You review and approve this plan
|
||||||
|
2. **Machine Hardening** — SSH into machine, verify state, apply hardening
|
||||||
|
3. **YubiKey Setup** — Configure both YubiKeys with Root and Intermediate CAs
|
||||||
|
4. **API Development** — Build the Certificate Management API and UI
|
||||||
|
5. **Integration** — Connect API to YubiKeys for signing
|
||||||
|
6. **Testing** — End-to-end testing of certificate issuance
|
||||||
|
7. **Setup Script** — Create reproducible setup script
|
||||||
|
8. **Documentation** — Final documentation and handover
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations & Trade-offs
|
||||||
|
|
||||||
|
### YubiKey Touch Policy
|
||||||
|
- `callback`: Prompts user to touch (most flexible, requires polling)
|
||||||
|
- `fixed-on`: Always requires touch (most secure, slightly slower UX)
|
||||||
|
- **Recommendation**: `fixed-on` for both keys
|
||||||
|
|
||||||
|
### Key Algorithm
|
||||||
|
- RSA 4096: Wider compatibility, larger keys/certs
|
||||||
|
- EC P-384: Smaller, faster, modern
|
||||||
|
- **Recommendation**: RSA 4096 for maximum compatibility with all services/browsers
|
||||||
|
|
||||||
|
### Signing Workflow
|
||||||
|
- Fully automated signing is **not possible** with YubiKey touch requirement
|
||||||
|
- Admin must be present to touch YubiKey and enter PIN
|
||||||
|
- This is a **feature**, not a limitation — it provides human-in-the-loop security
|
||||||
|
- For bulk operations, a CLI tool handles batch signing requests
|
||||||
|
|
||||||
|
### Offline Root CA
|
||||||
|
- YubiKey #1 (Root) can be removed after Intermediate CA is created
|
||||||
|
- Root is only needed if Intermediate CA key is compromised
|
||||||
|
- This provides true offline root CA capability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions for Confirmation
|
||||||
|
|
||||||
|
1. **Key algorithm**: RSA 4096 vs EC P-384? (RSA 4096 recommended for compatibility)
|
||||||
|
2. **Certificate validity**: Root: 25 years, Intermediate: 15 years, Leaf: configurable per-request?
|
||||||
|
3. **TOTP for admin login**: Enable two-factor authentication?
|
||||||
|
4. **Backup strategy**: Encrypted backups to local disk only, or also to network share?
|
||||||
|
5. **Additional domains**: Any specific domains to pre-register beyond `*.example.com`?
|
||||||
|
6. **CRL/OCSP**: Do we need Certificate Revocation List or OCSP responder? (Adds complexity)
|
||||||
|
7. **Admin username**: Should the admin account be `certauth` or a different username?
|
||||||
245
SKILL.md
Normal file
245
SKILL.md
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
# CertAuth Key Vault - API & Web UI Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CertAuth is a self-contained Certificate Authority running on `192.168.8.248` (accessible as `certauth.ms` on the local network). It uses two YubiKey 5 Nano devices as hardware security modules — one for the Root CA and one for the Intermediate CA — requiring physical touch + PIN to sign certificates.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Two auth methods are supported:
|
||||||
|
|
||||||
|
### JWT Token (API)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login
|
||||||
|
curl -X POST https://certauth.ms/api/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"certauth","password":"CHANGE_ME_ADMIN_PASS"}'
|
||||||
|
|
||||||
|
# Response: {"access_token": "eyJ...", "token_type": "bearer"}
|
||||||
|
|
||||||
|
# Use token in subsequent requests
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" https://certauth.ms/api/domains
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cookie Auth (Web UI / curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login via web endpoint (sets cookie)
|
||||||
|
curl -c cookies.txt -L https://certauth.ms/login \
|
||||||
|
-d "username=certauth&password=CHANGE_ME_ADMIN_PASS"
|
||||||
|
|
||||||
|
# Use cookie in subsequent requests
|
||||||
|
curl -b cookies.txt https://certauth.ms/api/domains/web
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use `-b "token=$TOKEN"` with the token value for cookie-auth endpoints.
|
||||||
|
|
||||||
|
## Admin Credentials
|
||||||
|
|
||||||
|
- **Username**: `certauth`
|
||||||
|
- **Password**: `CHANGE_ME_ADMIN_PASS`
|
||||||
|
- **PFX download password**: `certauth`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All endpoints require authentication. The server is at `https://certauth.ms`.
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://certauth.ms/api/health
|
||||||
|
# → {"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CA Chain Download
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://certauth.ms/api/ca-chain -o ca-chain.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the Intermediate + Root CA chain in PEM format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Domains
|
||||||
|
|
||||||
|
**List domains:**
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" https://certauth.ms/api/domains
|
||||||
|
```
|
||||||
|
|
||||||
|
**Register domain (API):**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://certauth.ms/api/domains \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d "name=example.com&description=My+website"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Register domain (web/cookie auth):**
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt -X POST https://certauth.ms/api/domains/web \
|
||||||
|
-d "name=example.com&description=My+website"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Certificates
|
||||||
|
|
||||||
|
**List certificates:**
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" https://certauth.ms/api/certs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request certificate (API):**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://certauth.ms/api/certs/request \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d "cn=example.com&sans=example.com%2Cwww.example.com&days=365&domain_id=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request certificate (web/cookie auth):**
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt -X POST https://certauth.ms/api/certs/web/request \
|
||||||
|
-d "cn=example.com&sans=example.com%2Cwww.example.com&days=365&domain_id=0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `cn` — Common Name (required)
|
||||||
|
- `sans` — Comma-separated Subject Alternative Names (URL-encoded)
|
||||||
|
- `days` — Validity in days (default: 365)
|
||||||
|
- `domain_id` — Domain ID to link to (0 = auto-match by CN)
|
||||||
|
|
||||||
|
**Sign/Issue certificate:**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://certauth.ms/api/certs/{cert_id}/sign \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sign/Issue certificate (web/cookie auth):**
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt -X POST https://certauth.ms/api/certs/{cert_id}/sign/web
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Signing requires physical YubiKey touch + PIN** — this will block until the operator touches YubiKey 2 and enters the PIN.
|
||||||
|
|
||||||
|
**Download PEM (cert + chain):**
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt https://certauth.ms/api/certs/{cert_id}/pem -o cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**Download PFX (cert + key + chain, password: `certauth`):**
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt https://certauth.ms/api/certs/{cert_id}/pfx -o cert.pfx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### History
|
||||||
|
|
||||||
|
**View certificate history:**
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt https://certauth.ms/history
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Setup Scripts
|
||||||
|
|
||||||
|
**Bash installer (Linux/macOS):**
|
||||||
|
```bash
|
||||||
|
curl -sL https://certauth.ms/setup.sh | sudo bash -s 192.168.8.248
|
||||||
|
```
|
||||||
|
|
||||||
|
**PowerShell installer (Windows):**
|
||||||
|
```powershell
|
||||||
|
iwr https://certauth.ms/setup.ps1 -UseBasicParsing | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Workflow Example
|
||||||
|
|
||||||
|
Here's a full end-to-end example to register a domain, request a cert, issue it, and download:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Login and get token
|
||||||
|
TOKEN=$(curl -s -X POST https://certauth.ms/api/token \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"certauth","password":"CHANGE_ME_ADMIN_PASS"}' \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
|
||||||
|
|
||||||
|
# 2. Register domain
|
||||||
|
curl -s -X POST https://certauth.ms/api/domains \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d "name=myapp.local&description=Internal+app"
|
||||||
|
|
||||||
|
# 3. Request certificate
|
||||||
|
CERT_ID=$(curl -s -X POST https://certauth.ms/api/certs/request \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d "cn=myapp.local&sans=myapp.local%2Cwww.myapp.local&days=365&domain_id=0" \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
|
|
||||||
|
echo "Certificate ID: $CERT_ID"
|
||||||
|
|
||||||
|
# 4. Issue certificate (requires YubiKey touch!)
|
||||||
|
curl -s -X POST https://certauth.ms/api/certs/$CERT_ID/sign \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# 5. Download PEM (cert + intermediate + root)
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://certauth.ms/api/certs/$CERT_ID/pem -o myapp.pem
|
||||||
|
|
||||||
|
# 6. Download PFX (cert + key + chain, password: certauth)
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://certauth.ms/api/certs/$CERT_ID/pfx -o myapp.pfx
|
||||||
|
|
||||||
|
# 7. Verify
|
||||||
|
openssl verify -CAfile ca-chain.crt myapp.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## Certificate Details
|
||||||
|
|
||||||
|
- **Key type**: EC P-384 (SECP384R1)
|
||||||
|
- **Signature algorithm**: ECDSA-SHA384
|
||||||
|
- **CA chain**: Intermediate CA (YubiKey 2) → Root CA (YubiKey 1)
|
||||||
|
- **Extensions**: Server Auth + Client Auth EKU, proper AKI/SKI
|
||||||
|
- **Validity**: 365 days per leaf cert (configurable)
|
||||||
|
- **PFX password**: `certauth`
|
||||||
|
|
||||||
|
## Installing CA Chain on Clients
|
||||||
|
|
||||||
|
The `setup.sh` endpoint serves a self-installing bash script that:
|
||||||
|
1. Auto-detects the server IP (or prompts)
|
||||||
|
2. Downloads the CA chain
|
||||||
|
3. Installs it to the OS trust store
|
||||||
|
4. Supports: Debian/Ubuntu, RHEL/CentOS/Fedora, Arch, Alpine, macOS
|
||||||
|
|
||||||
|
For macOS specifically, the CA is installed to the System Keychain with `security add-trusted-cert`.
|
||||||
|
|
||||||
|
For LibreWolf (which disables enterprise roots by default):
|
||||||
|
- Set `security.enterprise_roots.enabled = true` in `about:config`
|
||||||
|
- OR import the root CA via `certutil` into LibreWolf's NSS database
|
||||||
|
|
||||||
|
## Key Files on Server
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `/etc/ssl/ca/root/root-ca.crt` | Root CA certificate |
|
||||||
|
| `/etc/ssl/ca/intermediate/intermediate-ca.crt` | Intermediate CA certificate |
|
||||||
|
| `/etc/ssl/ca/ca-chain.crt` | Full chain (intermediate + root) |
|
||||||
|
| `/etc/ssl/ca/issued/` | Issued leaf certificates and keys |
|
||||||
|
| `/etc/ssl/certauth/tls.pem` | Server cert + chain (for Caddy) |
|
||||||
|
| `/etc/ssl/certauth/tls.key` | Server private key |
|
||||||
|
| `/opt/certauth/api/` | API application files |
|
||||||
|
| `/var/lib/certauth/certauth.db` | SQLite database |
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
- `certauth.ms` → `192.168.8.248` (served by dnsmasq on the server)
|
||||||
|
- Also added to router DNS for network-wide resolution
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **SSL_ERROR_BAD_CERT_DOMAIN**: Make sure you're using `https://certauth.ms` not the IP. Firefox/NSS has quirks with IP Address SANs.
|
||||||
|
- **SEC_ERROR_UNKNOWN_ISSUER**: The CA chain isn't trusted. Install via `setup.sh` or import manually.
|
||||||
|
- **Signing fails**: YubiKey 2 must be touched and PIN entered when signing.
|
||||||
|
- **DNS not resolving**: Check that `certauth.ms` is in your router's DNS or AdGuard Home config.
|
||||||
29
api/auth.py
Normal file
29
api/auth.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
|
from models import get_db, verify_password
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token")
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: timedelta = None):
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
return username
|
||||||
18
api/config.py
Normal file
18
api/config.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
YK_ROOT_SERIAL = "35450561"
|
||||||
|
YK_ROOT_PIN = os.environ.get("YK_ROOT_PIN", "CHANGE_ME_YK1_PIN")
|
||||||
|
YK_INT_SERIAL = "33930436"
|
||||||
|
YK_INT_PIN = os.environ.get("YK_INT_PIN", "CHANGE_ME_YK2_PIN")
|
||||||
|
ROOT_CA_PATH = "/etc/ssl/ca/root/root-ca.crt"
|
||||||
|
INT_CA_PATH = "/etc/ssl/ca/intermediate/intermediate-ca.crt"
|
||||||
|
CA_CHAIN_PATH = "/etc/ssl/ca/ca-chain.crt"
|
||||||
|
ISSUED_DIR = "/etc/ssl/ca/issued"
|
||||||
|
DB_PATH = "/var/lib/certauth/certauth.db"
|
||||||
|
SECRET_KEY = os.environ.get("JWT_SECRET", "CHANGE_ME_JWT_SECRET")
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
||||||
|
ADMIN_USERNAME = "certauth"
|
||||||
|
PKCS11_MODULE = "/usr/lib/aarch64-linux-gnu/opensc-pkcs11.so"
|
||||||
|
YK_PUB_ROOT = "/tmp/yk1-root-pub.pem"
|
||||||
|
YK_PUB_INT = "/tmp/yk2-int-pub.pem"
|
||||||
482
api/main.py
Normal file
482
api/main.py
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
import os, sqlite3, datetime, secrets, hashlib, subprocess, json
|
||||||
|
from fastapi import FastAPI, Request, Depends, HTTPException, Form
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from jose import jwt
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
from config import *
|
||||||
|
from models import get_db, init_db, hash_password, verify_password
|
||||||
|
from auth import create_access_token, get_current_user
|
||||||
|
from signing import build_leaf_cert
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
app = FastAPI(title="CertAuth Key Vault")
|
||||||
|
app.mount("/static", StaticFiles(directory="/opt/certauth/api/static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_from_cookie(request: Request):
|
||||||
|
token = request.cookies.get("token")
|
||||||
|
if not token: return None
|
||||||
|
try: return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
except: return None
|
||||||
|
|
||||||
|
jinja_env = Environment(
|
||||||
|
loader=FileSystemLoader("/opt/certauth/api/templates"),
|
||||||
|
autoescape=select_autoescape(["html"])
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup():
|
||||||
|
init_db()
|
||||||
|
try:
|
||||||
|
with open(ROOT_CA_PATH) as f: root = f.read()
|
||||||
|
with open(INT_CA_PATH) as f: inter = f.read()
|
||||||
|
with open(CA_CHAIN_PATH, "w") as f: f.write(inter + "\n" + root)
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
def render(name, ctx):
|
||||||
|
return HTMLResponse(jinja_env.get_template(name).render(**ctx))
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@app.post("/api/token")
|
||||||
|
async def login(req: LoginRequest):
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT * FROM users WHERE username = ?", (req.username,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or not verify_password(req.password, row["password_hash"]):
|
||||||
|
raise HTTPException(401, "Invalid credentials")
|
||||||
|
token = create_access_token({"sub": req.username})
|
||||||
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
@app.get("/api/me")
|
||||||
|
async def me(user: str = Depends(get_current_user)):
|
||||||
|
return {"username": user}
|
||||||
|
|
||||||
|
@app.get("/api/domains")
|
||||||
|
async def list_domains(user: str = Depends(get_current_user)):
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("SELECT * FROM domains ORDER BY created_at DESC").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
@app.post("/api/domains")
|
||||||
|
async def create_domain(name: str = Form(...), description: str = Form(""),
|
||||||
|
user: str = Depends(get_current_user)):
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("INSERT INTO domains (name, description, created_by) VALUES (?,?,?)",
|
||||||
|
(name, description, 1))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.get("/api/certs")
|
||||||
|
async def list_certs(user: str = Depends(get_current_user)):
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("SELECT c.*, d.name as domain_name FROM certificates c LEFT JOIN domains d ON c.domain_id = d.id ORDER BY c.created_at DESC").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
@app.post("/api/certs/request")
|
||||||
|
async def request_cert(cn: str = Form(...), sans: str = Form(""),
|
||||||
|
days: int = Form(365), domain_id: int = Form(0),
|
||||||
|
user: str = Depends(get_current_user)):
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("INSERT INTO certificates (domain_id, subject, san, status, created_by) VALUES (?,?,?,?,?)",
|
||||||
|
(domain_id, cn, sans, "pending", 1))
|
||||||
|
conn.commit()
|
||||||
|
cid = cur.lastrowid
|
||||||
|
conn.close()
|
||||||
|
return {"status": "ok", "id": cid}
|
||||||
|
|
||||||
|
@app.post("/api/certs/{cert_id}/sign")
|
||||||
|
async def sign_cert(cert_id: int, user: str = Depends(get_current_user)):
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone()
|
||||||
|
if not row or row["status"] != "pending":
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(400, "Not found or already signed")
|
||||||
|
conn.close()
|
||||||
|
result, err = build_leaf_cert(row["subject"], row["san"], 365)
|
||||||
|
if err: raise HTTPException(500, f"Signing failed: {err}")
|
||||||
|
cf = f"/etc/ssl/ca/issued/cert-{result['serial']}.crt"
|
||||||
|
kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key"
|
||||||
|
open(cf, "w").write(result["cert_pem"])
|
||||||
|
open(kf, "w").write(result["key_pem"])
|
||||||
|
os.chmod(cf, 0o640); os.chmod(kf, 0o600)
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("UPDATE certificates SET status=?, serial=?, cert_path=?, issued_at=?, expires_at=? WHERE id=?",
|
||||||
|
("issued", result["serial"], cf, datetime.datetime.now().isoformat(), result["expires_at"], cert_id))
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
return {"status": "ok", "serial": result["serial"]}
|
||||||
|
|
||||||
|
@app.get("/api/certs/{cert_id}/pem")
|
||||||
|
async def download_pem(cert_id: int, request: Request = None):
|
||||||
|
"""Download cert + chain as bundled PEM."""
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user: raise HTTPException(401, "Login required")
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or row["status"] != "issued": raise HTTPException(404)
|
||||||
|
pem_path = f"/var/lib/certauth/tmp/cert-{row['serial']}.pem"
|
||||||
|
with open(row["cert_path"]) as f:
|
||||||
|
cert_pem = f.read()
|
||||||
|
with open(CA_CHAIN_PATH) as f:
|
||||||
|
chain_pem = f.read()
|
||||||
|
with open(pem_path, "w") as f:
|
||||||
|
f.write(cert_pem.rstrip() + "\n" + chain_pem)
|
||||||
|
return FileResponse(pem_path, media_type="application/x-pem-file", filename=f"cert-{row['serial']}.pem")
|
||||||
|
|
||||||
|
@app.get("/api/certs/{cert_id}/pfx")
|
||||||
|
async def download_pfx(cert_id: int, password: str = "certauth", request: Request = None):
|
||||||
|
"""Download cert + key + chain as PKCS12/PFX."""
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user: raise HTTPException(401, "Login required")
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or row["status"] != "issued": raise HTTPException(404)
|
||||||
|
kf = row["cert_path"].replace(".crt", ".key")
|
||||||
|
if not os.path.exists(kf): raise HTTPException(404)
|
||||||
|
from cryptography.hazmat.primitives.serialization import pkcs12, BestAvailableEncryption
|
||||||
|
from cryptography import x509
|
||||||
|
with open(row["cert_path"], "rb") as f:
|
||||||
|
leaf = x509.load_pem_x509_certificate(f.read())
|
||||||
|
with open(kf, "rb") as f:
|
||||||
|
key = serialization.load_pem_private_key(f.read(), password=None)
|
||||||
|
chain_certs = []
|
||||||
|
with open(CA_CHAIN_PATH, "rb") as f:
|
||||||
|
for cert_pem in f.read().split(b"-----END CERTIFICATE-----"):
|
||||||
|
cert_pem = cert_pem.strip()
|
||||||
|
if cert_pem:
|
||||||
|
chain_certs.append(x509.load_pem_x509_certificate(cert_pem + b"\n-----END CERTIFICATE-----"))
|
||||||
|
pfx_data = pkcs12.serialize_key_and_certificates(
|
||||||
|
name=row["subject"].encode(),
|
||||||
|
key=key,
|
||||||
|
cert=leaf,
|
||||||
|
cas=chain_certs or None,
|
||||||
|
encryption_algorithm=BestAvailableEncryption(password.encode())
|
||||||
|
)
|
||||||
|
pfx_path = f"/var/lib/certauth/tmp/cert-{row['serial']}.pfx"
|
||||||
|
with open(pfx_path, "wb") as f:
|
||||||
|
f.write(pfx_data)
|
||||||
|
return FileResponse(pfx_path, media_type="application/x-pkcs12", filename=f"cert-{row['serial']}.pfx")
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health(): return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.get("/api/ca-chain")
|
||||||
|
async def ca_chain(): return FileResponse(CA_CHAIN_PATH, filename="ca-chain.crt")
|
||||||
|
|
||||||
|
def get_user_from_cookie(request: Request):
|
||||||
|
token = request.cookies.get("token")
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
conn = get_db()
|
||||||
|
certs = conn.execute("SELECT c.*, d.name as domain_name FROM certificates c LEFT JOIN domains d ON c.domain_id = d.id ORDER BY c.created_at DESC LIMIT 20").fetchall()
|
||||||
|
domains = conn.execute("SELECT * FROM domains").fetchall()
|
||||||
|
p = conn.execute("SELECT COUNT(*) as c FROM certificates WHERE status = ?", ("pending",)).fetchone()["c"]
|
||||||
|
i = conn.execute("SELECT COUNT(*) as c FROM certificates WHERE status = ?", ("issued",)).fetchone()["c"]
|
||||||
|
conn.close()
|
||||||
|
return render("dashboard.html", {"request": request, "user": user,
|
||||||
|
"certs": [dict(r) for r in certs], "domains": [dict(r) for r in domains],
|
||||||
|
"pending": p, "issued": i})
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request):
|
||||||
|
return render("login.html", {"request": request, "error": None})
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
async def login_post(username: str = Form(...), password: str = Form(...)):
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or not verify_password(password, row["password_hash"]):
|
||||||
|
return render("login.html", {"request": None, "error": "Invalid credentials"})
|
||||||
|
token = create_access_token({"sub": username})
|
||||||
|
resp = RedirectResponse("/", status_code=302)
|
||||||
|
resp.set_cookie("token", token, httponly=True, samesite="lax", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/certs/{cert_id}/sign/web")
|
||||||
|
async def sign_cert_web(cert_id: int, request: Request = None):
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone()
|
||||||
|
if not row or row["status"] != "pending":
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(400, "Not found or already issued")
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
result, err = build_leaf_cert(row["subject"], row["san"], 365)
|
||||||
|
if err:
|
||||||
|
return HTMLResponse(f'<span class="text-red-400">Issue failed: {err}</span>')
|
||||||
|
cf = f"/etc/ssl/ca/issued/cert-{result['serial']}.crt"
|
||||||
|
kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key"
|
||||||
|
open(cf, "w").write(result["cert_pem"])
|
||||||
|
open(kf, "w").write(result["key_pem"])
|
||||||
|
os.chmod(cf, 0o640)
|
||||||
|
os.chmod(kf, 0o600)
|
||||||
|
conn2 = get_db()
|
||||||
|
conn2.execute("UPDATE certificates SET status=?, serial=?, cert_path=?, issued_at=?, expires_at=? WHERE id=?",
|
||||||
|
("issued", result["serial"], cf, datetime.datetime.now().isoformat(), result["expires_at"], cert_id))
|
||||||
|
conn2.commit()
|
||||||
|
conn2.close()
|
||||||
|
return HTMLResponse(f'<span class="text-green-400">Issued! <a href="/api/certs/{cert_id}/pem" class="underline">PEM</a> | <a href="/api/certs/{cert_id}/pfx" class="underline">PFX</a> | <a href="/certs" class="underline">Refresh</a></span>')
|
||||||
|
except Exception as ex:
|
||||||
|
return HTMLResponse(f'<span class="text-red-400">Issue failed: {str(ex)}</span>')
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
async def logout():
|
||||||
|
resp = RedirectResponse("/login", status_code=302)
|
||||||
|
resp.delete_cookie("token", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# --- Web API (cookie auth) ---
|
||||||
|
@app.post("/api/domains/web")
|
||||||
|
async def create_domain_web(name: str = Form(...), description: str = Form(""), request: Request = None):
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("INSERT INTO domains (name, description, created_by) VALUES (?,?,?)", (name, description, 1))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return HTMLResponse('<span class="text-green-400">Domain registered! <a href="/domains" class="underline">Refresh</a></span>')
|
||||||
|
|
||||||
|
@app.post("/api/certs/web/request")
|
||||||
|
async def request_cert_web(cn: str = Form(...), sans: str = Form(""), days: int = Form(365), domain_id: int = Form(0), request: Request = None):
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.cursor()
|
||||||
|
# Look up domain by CN if domain_id not provided
|
||||||
|
if domain_id == 0:
|
||||||
|
cur.execute("SELECT id FROM domains WHERE name=?", (cn,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
domain_id = row[0] if row else None
|
||||||
|
cur.execute("INSERT INTO certificates (domain_id, subject, san, status, created_by) VALUES (?,?,?,?,?)", (domain_id, cn, sans, "pending", 1))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return HTMLResponse('<span class="text-green-400">Certificate requested! Click Issue below. <a href="/certs" class="underline">Refresh</a></span>')
|
||||||
|
|
||||||
|
@app.get("/domains", response_class=HTMLResponse)
|
||||||
|
async def domains_page(request: Request):
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user: return RedirectResponse("/login", status_code=302)
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("SELECT * FROM domains ORDER BY created_at DESC").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return render("domains.html", {"request": request, "user": user, "domains": [dict(r) for r in rows]})
|
||||||
|
|
||||||
|
@app.get("/certs", response_class=HTMLResponse)
|
||||||
|
async def certs_page(request: Request):
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user: return RedirectResponse("/login", status_code=302)
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("SELECT c.*, d.name as domain_name FROM certificates c LEFT JOIN domains d ON c.domain_id = d.id ORDER BY c.created_at DESC").fetchall()
|
||||||
|
domains = conn.execute("SELECT * FROM domains").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return render("certs.html", {"request": request, "user": user, "certs": [dict(r) for r in rows],
|
||||||
|
"domains": [dict(r) for r in domains]})
|
||||||
|
|
||||||
|
@app.get("/history", response_class=HTMLResponse)
|
||||||
|
async def history_page(request: Request):
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user: return RedirectResponse("/login", status_code=302)
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("SELECT c.*, d.name as domain_name FROM certificates c LEFT JOIN domains d ON c.domain_id = d.id ORDER BY c.created_at DESC").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return render("history.html", {"request": request, "user": user, "certs": [dict(r) for r in rows]})
|
||||||
|
|
||||||
|
@app.get("/setup", response_class=HTMLResponse)
|
||||||
|
async def setup_page(request: Request):
|
||||||
|
user = get_user_from_cookie(request)
|
||||||
|
if not user: return RedirectResponse("/login", status_code=302)
|
||||||
|
return render("setup.html", {"request": request, "user": user})
|
||||||
|
|
||||||
|
@app.get("/setup.sh")
|
||||||
|
async def setup_sh():
|
||||||
|
"""One-liner bash setup script for Linux/macOS."""
|
||||||
|
script = r'''#!/bin/bash
|
||||||
|
set -e
|
||||||
|
# CertAuth CA Chain Installer
|
||||||
|
# Usage: curl -sL http://<certauth-ip>/setup.sh | bash
|
||||||
|
# curl -sL http://<certauth-ip>/setup.sh | sudo bash
|
||||||
|
|
||||||
|
# Auto-detect CertAuth server IP
|
||||||
|
DETECTED_IP=""
|
||||||
|
if [[ -n "$1" ]]; then
|
||||||
|
DETECTED_IP="$1"
|
||||||
|
elif [[ -n "$CERTAUTH_IP" ]]; then
|
||||||
|
DETECTED_IP="$CERTAUTH_IP"
|
||||||
|
else
|
||||||
|
# Try Linux hostname -I first
|
||||||
|
DETECTED_IP=$(hostname -I 2>/dev/null | awk '{print $1}') || true
|
||||||
|
# Fallback: ip route (Linux)
|
||||||
|
[[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(ip route get 1 2>/dev/null | awk '{print $7}' | head -1) || true
|
||||||
|
# Fallback: ifconfig (macOS/BSD)
|
||||||
|
[[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(ifconfig 2>/dev/null | grep -E '^\s+(inet )' | awk '{print $2}' | grep -v '127.0.0.1' | head -1) || true
|
||||||
|
# Fallback: networksetup (macOS only)
|
||||||
|
[[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(networksetup -getinfo $(networksetup -listallhardwareports 2>/dev/null | awk '/Hardware Port:/ {getline; gsub(/^[ \t]+/, ""); print}') 2>/dev/null | grep 'IP address:' | awk '{print $3}') || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prompt if auto-detection failed
|
||||||
|
if [[ -z "$DETECTED_IP" ]]; then
|
||||||
|
read -r -p "Enter CertAuth server IP (e.g., 192.168.8.248): " DETECTED_IP
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHAIN_URL="http://$DETECTED_IP/api/ca-chain"
|
||||||
|
|
||||||
|
echo "Downloading CA chain..."
|
||||||
|
curl -sLk "$CHAIN_URL" -o /tmp/ca-chain.crt || { echo "Failed to download CA chain from $CHAIN_URL"; exit 1; }
|
||||||
|
|
||||||
|
# Detect OS and install
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
. /etc/os-release
|
||||||
|
if [[ "$ID" == "debian" || "$ID" == "ubuntu" || "$ID" == "linuxmint" ]]; then
|
||||||
|
sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
echo "✅ CA chain installed (Debian/Ubuntu)"
|
||||||
|
elif [[ "$ID" == "centos" || "$ID" == "rhel" || "$ID" == "fedora" ]]; then
|
||||||
|
# Install to system trust store (curl, openssl, etc.)
|
||||||
|
sudo cp /tmp/ca-chain.crt /etc/pki/ca-trust/source/anchors/certauth.crt
|
||||||
|
sudo update-ca-trust
|
||||||
|
|
||||||
|
# Install root CA to NSS database (Firefox, Thunderbird, etc.)
|
||||||
|
# The root CA is the self-signed cert (second cert in chain)
|
||||||
|
if command -v certutil &>/dev/null; then
|
||||||
|
sudo certutil -D -n "CertAuth Root CA" -d sql:/etc/pki/nssdb/ 2>/dev/null || true
|
||||||
|
python3 -c "
|
||||||
|
import re, subprocess
|
||||||
|
with open('/tmp/ca-chain.crt') as f:
|
||||||
|
content = f.read()
|
||||||
|
certs = re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', content, re.DOTALL)
|
||||||
|
# Find the self-signed root cert (subject == issuer)
|
||||||
|
for cert in certs:
|
||||||
|
subj = subprocess.run(['openssl', 'x509', '-noout', '-subject'], input=cert, capture_output=True, text=True).stdout
|
||||||
|
iss = subprocess.run(['openssl', 'x509', '-noout', '-issuer'], input=cert, capture_output=True, text=True).stdout
|
||||||
|
if subj.replace('subject=', '') == iss.replace('issuer=', ''):
|
||||||
|
with open('/tmp/certauth-root.crt', 'w') as rf:
|
||||||
|
rf.write(cert + '\n')
|
||||||
|
break
|
||||||
|
" 2>/dev/null
|
||||||
|
if [[ -f /tmp/certauth-root.crt ]]; then
|
||||||
|
sudo certutil -A -n "CertAuth Root CA" -t "CT,Cu,Tu" -d sql:/etc/pki/nssdb/ -i /tmp/certauth-root.crt 2>/dev/null
|
||||||
|
echo "✅ Root CA imported to NSS database"
|
||||||
|
rm -f /tmp/certauth-root.crt
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "✅ CA chain installed (RHEL/CentOS/Fedora)"
|
||||||
|
elif [[ "$ID" == "arch" ]]; then
|
||||||
|
sudo cp /tmp/ca-chain.crt /etc/ca-certificates/trust-source/anchors/certauth.crt
|
||||||
|
sudo update-ca-trust
|
||||||
|
echo "✅ CA chain installed (Arch)"
|
||||||
|
elif [[ "$ID" == "alpine" ]]; then
|
||||||
|
sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
echo "✅ CA chain installed (Alpine)"
|
||||||
|
else
|
||||||
|
echo "❌ Unsupported Linux distribution: $ID"
|
||||||
|
echo " Download /tmp/ca-chain.crt and install manually"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [[ "$(uname)" == "Darwin" ]]; then
|
||||||
|
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/ca-chain.crt
|
||||||
|
echo "✅ CA chain installed (macOS)"
|
||||||
|
else
|
||||||
|
echo "❌ Unsupported OS: $(uname -s)"
|
||||||
|
echo " Download /tmp/ca-chain.crt and install manually"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
VERIFY_URL="${CHAIN_URL%/api/ca-chain}"
|
||||||
|
if curl -sL "$VERIFY_URL" &>/dev/null; then
|
||||||
|
echo "🌐 Server at $VERIFY_URL is reachable"
|
||||||
|
else
|
||||||
|
echo "⚠️ Server at $VERIFY_URL is not reachable (expected if not on same network)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f /tmp/ca-chain.crt
|
||||||
|
echo "Done!"
|
||||||
|
'''
|
||||||
|
return PlainTextResponse(script, media_type="text/x-shellscript")
|
||||||
|
|
||||||
|
@app.get("/setup.ps1")
|
||||||
|
async def setup_ps1():
|
||||||
|
"""PowerShell setup script for Windows."""
|
||||||
|
script = r'''# CertAuth CA Chain Installer for Windows
|
||||||
|
# Usage: iex (New-Object Net.WebClient).DownloadString("http://<certauth-ip>/setup.ps1")
|
||||||
|
# iwr http://<certauth-ip>/setup.ps1 -UseBasicParsing | iex
|
||||||
|
|
||||||
|
param([string]$CertAuthIP = "")
|
||||||
|
|
||||||
|
if (-not $CertAuthIP) {
|
||||||
|
# Try to detect from environment or prompt
|
||||||
|
$CertAuthIP = Read-Host "Enter CertAuth server IP (e.g., 192.168.8.248)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ChainUrl = "http://$CertAuthIP/api/ca-chain"
|
||||||
|
$ChainPath = "$env:TEMP\ca-chain.crt"
|
||||||
|
|
||||||
|
Write-Host "Downloading CA chain from $ChainUrl ..." -ForegroundColor Cyan
|
||||||
|
try {
|
||||||
|
(New-Object Net.WebClient).DownloadFile($ChainUrl, $ChainPath)
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to download CA chain: $_" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install to Local Machine Trusted Root store
|
||||||
|
Write-Host "Installing to Trusted Root Certification Authorities..." -ForegroundColor Cyan
|
||||||
|
try {
|
||||||
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||||
|
[System.Security.Cryptography.X509Certificates.StoreName]::Root,
|
||||||
|
[System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
|
||||||
|
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
|
||||||
|
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($ChainPath)
|
||||||
|
$store.Add($cert)
|
||||||
|
$store.Close()
|
||||||
|
Write-Host "CA chain installed successfully!" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to install: $_" -ForegroundColor Red
|
||||||
|
Write-Host "Run as Administrator and try again." -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
try {
|
||||||
|
$response = Invoke-WebRequest -Uri "http://$CertAuthIP/api/health" -TimeoutSec 3 -ErrorAction Stop
|
||||||
|
Write-Host "Server at http://$CertAuthIP is reachable." -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "Server at http://$CertAuthIP is not reachable (expected if not on same network)." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item $ChainPath -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "Done!" -ForegroundColor Green
|
||||||
|
'''
|
||||||
|
return PlainTextResponse(script, media_type="text/plain")
|
||||||
77
api/models.py
Normal file
77
api/models.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import sqlite3, datetime, secrets, bcrypt, os
|
||||||
|
from config import DB_PATH, ADMIN_USERNAME
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = get_db()
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS domains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER REFERENCES users(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS certificates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain_id INTEGER REFERENCES domains(id),
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
san TEXT,
|
||||||
|
serial TEXT UNIQUE,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
cert_path TEXT,
|
||||||
|
issued_at TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER REFERENCES users(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_hash TEXT UNIQUE NOT NULL,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
permissions TEXT DEFAULT 'read',
|
||||||
|
active BOOLEAN DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER REFERENCES users(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT id FROM users WHERE username = ?", (ADMIN_USERNAME,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
pw_hash = bcrypt.hashpw(b"CHANGE_ME_ADMIN_PASS", bcrypt.gensalt())
|
||||||
|
if isinstance(pw_hash, bytes): pw_hash = pw_hash.decode()
|
||||||
|
cur.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||||
|
(ADMIN_USERNAME, pw_hash))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def hash_password(password):
|
||||||
|
h = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
|
||||||
|
return h.decode() if isinstance(h, bytes) else h
|
||||||
|
|
||||||
|
def verify_password(password, hash_):
|
||||||
|
if isinstance(hash_, str): hash_ = hash_.encode()
|
||||||
|
return bcrypt.checkpw(password.encode(), hash_)
|
||||||
138
api/signing.py
Normal file
138
api/signing.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
|
||||||
|
import subprocess, datetime, os, hashlib, ipaddress, re
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from config import *
|
||||||
|
|
||||||
|
TMP_DIR = "/var/lib/certauth/tmp"
|
||||||
|
os.makedirs(TMP_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
def get_root_pub_key():
|
||||||
|
with open(YK_PUB_ROOT, "rb") as f:
|
||||||
|
return serialization.load_pem_public_key(f.read())
|
||||||
|
|
||||||
|
def get_int_pub_key():
|
||||||
|
with open(YK_PUB_INT, "rb") as f:
|
||||||
|
return serialization.load_pem_public_key(f.read())
|
||||||
|
|
||||||
|
def get_root_ca_cert():
|
||||||
|
with open(ROOT_CA_PATH, "rb") as f:
|
||||||
|
return x509.load_pem_x509_certificate(f.read())
|
||||||
|
|
||||||
|
def der_len(n):
|
||||||
|
if n < 0x80: return bytes([n])
|
||||||
|
elif n < 0x100: return bytes([0x81, n])
|
||||||
|
return bytes([0x82, n>>8, n&0xff])
|
||||||
|
|
||||||
|
def sign_tbs_with_yk(tbs_bytes, yk_pin, token_label="certauth Intermediate CA"):
|
||||||
|
tbs_file = os.path.join(TMP_DIR, "tbs_sign.der")
|
||||||
|
sig_file = os.path.join(TMP_DIR, "sig_out.bin")
|
||||||
|
with open(tbs_file, "wb") as f:
|
||||||
|
f.write(tbs_bytes)
|
||||||
|
r = subprocess.run([
|
||||||
|
"sudo", "pkcs11-tool", "--module", PKCS11_MODULE,
|
||||||
|
"--login", "--pin", yk_pin,
|
||||||
|
"--sign", "--mechanism", "ECDSA-SHA384",
|
||||||
|
"--token-label", token_label,
|
||||||
|
"--label", "SIGN key",
|
||||||
|
"--input-file", tbs_file,
|
||||||
|
"--output-file", sig_file
|
||||||
|
], capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None, r.stderr
|
||||||
|
with open(sig_file, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
rb = raw[:48].lstrip(b"\x00") or b"\x00"
|
||||||
|
sb = raw[48:].lstrip(b"\x00") or b"\x00"
|
||||||
|
if rb[0] & 0x80: rb = b"\x00" + rb
|
||||||
|
if sb[0] & 0x80: sb = b"\x00" + sb
|
||||||
|
r_der = b"\x02" + bytes([len(rb)]) + rb
|
||||||
|
s_der = b"\x02" + bytes([len(sb)]) + sb
|
||||||
|
seq = b"\x30" + bytes([len(r_der+s_der)]) + r_der + s_der
|
||||||
|
bs = b"\x00" + seq
|
||||||
|
return b"\x03" + bytes([len(bs)]) + bs, None
|
||||||
|
|
||||||
|
def build_leaf_cert(cn, sans, days=365):
|
||||||
|
root_cert = get_root_ca_cert()
|
||||||
|
int_pub = get_int_pub_key()
|
||||||
|
root_pub = get_root_pub_key()
|
||||||
|
leaf_key = ec.generate_private_key(ec.SECP384R1())
|
||||||
|
subject = x509.Name([
|
||||||
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Home"),
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, cn),
|
||||||
|
])
|
||||||
|
issuer = x509.Name([
|
||||||
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Home"),
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, "certauth Intermediate CA"),
|
||||||
|
])
|
||||||
|
builder = (x509.CertificateBuilder()
|
||||||
|
.subject_name(subject).issuer_name(issuer)
|
||||||
|
.public_key(leaf_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
|
||||||
|
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=days))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||||
|
.add_extension(x509.KeyUsage(
|
||||||
|
digital_signature=True, key_encipherment=True,
|
||||||
|
key_cert_sign=False, crl_sign=False,
|
||||||
|
content_commitment=False, data_encipherment=False,
|
||||||
|
key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
|
||||||
|
.add_extension(x509.ExtendedKeyUsage([
|
||||||
|
x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
|
||||||
|
x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
|
||||||
|
]), critical=False)
|
||||||
|
.add_extension(x509.SubjectKeyIdentifier.from_public_key(leaf_key.public_key()), critical=False)
|
||||||
|
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(int_pub), critical=False))
|
||||||
|
if sans:
|
||||||
|
san_list = []
|
||||||
|
for s in sans.split(","):
|
||||||
|
s = s.strip()
|
||||||
|
# Check if it's an IP address
|
||||||
|
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", s):
|
||||||
|
san_list.append(x509.IPAddress(ipaddress.ip_address(s)))
|
||||||
|
else:
|
||||||
|
san_list.append(x509.DNSName(s))
|
||||||
|
builder = builder.add_extension(x509.SubjectAlternativeName(san_list), critical=False)
|
||||||
|
tmp = ec.generate_private_key(ec.SECP384R1())
|
||||||
|
temp = builder.sign(tmp, hashes.SHA384())
|
||||||
|
td = temp.public_bytes(serialization.Encoding.DER)
|
||||||
|
o = 1
|
||||||
|
if td[o] & 0x80: n = td[o] & 0x7f; o += 1 + n
|
||||||
|
else: o += 1
|
||||||
|
tbs_start = o
|
||||||
|
o += 1
|
||||||
|
if td[o] & 0x80: n = td[o] & 0x7f; tl = int.from_bytes(td[o+1:o+1+n], "big"); o += 1 + n
|
||||||
|
else: tl = td[o]; o += 1
|
||||||
|
tbs_end = o + tl
|
||||||
|
tbs_full = td[tbs_start:tbs_end]
|
||||||
|
alg_start = tbs_end
|
||||||
|
o2 = alg_start + 1
|
||||||
|
if td[o2] & 0x80: n = td[o2] & 0x7f; al = int.from_bytes(td[o2+1:o2+1+n], "big"); o2 += 1 + n
|
||||||
|
else: al = td[o2]; o2 += 1
|
||||||
|
alg_full = td[alg_start:o2+al]
|
||||||
|
new_sig, err = sign_tbs_with_yk(tbs_full, YK_INT_PIN)
|
||||||
|
if new_sig is None: return None, err
|
||||||
|
content = tbs_full + alg_full + new_sig
|
||||||
|
cl = len(content)
|
||||||
|
final = b"\x30\x82" + bytes([cl>>8, cl&0xff]) + content
|
||||||
|
der_file = os.path.join(TMP_DIR, "leaf.der")
|
||||||
|
pem_file = os.path.join(TMP_DIR, "leaf.pem")
|
||||||
|
with open(der_file, "wb") as f: f.write(final)
|
||||||
|
r = subprocess.run(["openssl", "x509", "-inform", "DER", "-outform", "PEM",
|
||||||
|
"-in", der_file, "-out", pem_file],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if r.returncode != 0: return None, r.stderr
|
||||||
|
with open(pem_file) as f: leaf_pem = f.read()
|
||||||
|
key_pem = leaf_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption()
|
||||||
|
).decode()
|
||||||
|
cert = x509.load_pem_x509_certificate(leaf_pem.encode())
|
||||||
|
serial = format(cert.serial_number, 'x')
|
||||||
|
return {"cert_pem": leaf_pem, "key_pem": key_pem, "serial": serial,
|
||||||
|
"expires_at": cert.not_valid_after.isoformat()}, None
|
||||||
30
api/templates/base.html
Normal file
30
api/templates/base.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="bg-gray-900 text-gray-100">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CertAuth{% block title %}{% endblock %}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen">
|
||||||
|
{% if user %}
|
||||||
|
<nav class="bg-gray-800 border-b border-gray-700 px-6 py-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<a href="/" class="font-bold text-lg text-blue-400 hover:text-blue-300">CertAuth</a>
|
||||||
|
<a href="/domains" class="hover:text-blue-400">Domains</a>
|
||||||
|
<a href="/certs" class="hover:text-blue-400">Certificates</a>
|
||||||
|
<a href="/history" class="hover:text-blue-400">History</a>
|
||||||
|
<a href="/setup" class="hover:text-blue-400">Setup</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm text-gray-400">{{ user.get("sub", "") }}</span>
|
||||||
|
<a href="/logout" class="text-sm text-gray-400 hover:text-white">Logout</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
<main class="p-6 max-w-6xl mx-auto">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
67
api/templates/certs.html
Normal file
67
api/templates/certs.html
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} - Certificates{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Certificates</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 mb-6">
|
||||||
|
<h2 class="font-bold mb-4">Request New Certificate</h2>
|
||||||
|
<form hx-post="/api/certs/web/request" hx-swap="innerHTML" hx-target="#cert-result"
|
||||||
|
class="flex gap-3 flex-wrap">
|
||||||
|
<input name="cn" placeholder="CN (e.g., git.example.com)" required
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white flex-1 min-w-[200px] focus:outline-none focus:border-blue-500">
|
||||||
|
<input name="sans" placeholder="SANs (comma-separated, e.g., git.example.com,*.git.example.com)"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white flex-1 min-w-[200px] focus:outline-none focus:border-blue-500">
|
||||||
|
<select name="domain_id" class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500">
|
||||||
|
<option value="0">No domain</option>
|
||||||
|
{% for d in domains %}
|
||||||
|
<option value="{{ d.id }}">{{ d.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded font-medium">Request</button>
|
||||||
|
</form>
|
||||||
|
<div id="cert-result" class="mt-3 text-sm"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="border-b border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left p-3 text-gray-400">Subject</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Domain</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Status</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Expires</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in certs %}
|
||||||
|
<tr class="border-b border-gray-700/50">
|
||||||
|
<td class="p-3 font-mono">{{ c.subject }}</td>
|
||||||
|
<td class="p-3">{{ c.domain_name or '-' }}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<span class="px-2 py-1 rounded text-xs {% if c.status == 'issued' %}bg-green-900 text-green-300{% elif c.status == 'pending' %}bg-yellow-900 text-yellow-300{% endif %}">
|
||||||
|
{{ c.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-gray-400">{{ c.expires_at[:10] if c.expires_at else '-' }}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
{% if c.status == 'issued' %}
|
||||||
|
<a href="/api/certs/{{ c.id }}/pem" class="text-blue-400 hover:text-blue-300 mr-2">PEM</a>
|
||||||
|
<a href="/api/certs/{{ c.id }}/pfx" class="text-blue-400 hover:text-blue-300">PFX</a>
|
||||||
|
{% elif c.status == 'pending' %}
|
||||||
|
<button hx-post="/api/certs/{{ c.id }}/sign/web" hx-target="#sign-msg-{{ c.id }}"
|
||||||
|
class="text-yellow-400 hover:text-yellow-300">Issue</button>
|
||||||
|
<span id="sign-msg-{{ c.id }}" class="ml-2"></span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not certs %}
|
||||||
|
<tr><td colspan="5" class="p-6 text-center text-gray-500">No certificates yet</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
62
api/templates/dashboard.html
Normal file
62
api/templates/dashboard.html
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} - Dashboard{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="text-sm text-gray-400">Issued Certificates</div>
|
||||||
|
<div class="text-3xl font-bold text-green-400">{{ issued }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="text-sm text-gray-400">Pending Issue</div>
|
||||||
|
<div class="text-3xl font-bold text-yellow-400">{{ pending }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="text-sm text-gray-400">Registered Domains</div>
|
||||||
|
<div class="text-3xl font-bold text-blue-400">{{ domains|length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-bold mb-4">Recent Certificates</h2>
|
||||||
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-750 border-b border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left p-3 text-gray-400">Subject</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Domain</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Status</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Expires</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in certs %}
|
||||||
|
<tr class="border-b border-gray-700/50">
|
||||||
|
<td class="p-3 font-mono">{{ c.subject }}</td>
|
||||||
|
<td class="p-3">{{ c.domain_name or '-' }}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<span class="px-2 py-1 rounded text-xs {% if c.status == 'issued' %}bg-green-900 text-green-300{% elif c.status == 'pending' %}bg-yellow-900 text-yellow-300{% else %}bg-gray-700{% endif %}">
|
||||||
|
{{ c.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-gray-400">{{ c.expires_at[:10] if c.expires_at else '-' }}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
{% if c.status == 'issued' %}
|
||||||
|
<a href="/api/certs/{{ c.id }}/pem" class="text-blue-400 hover:text-blue-300 mr-2">PEM</a>
|
||||||
|
<a href="/api/certs/{{ c.id }}/pfx" class="text-blue-400 hover:text-blue-300">PFX</a>
|
||||||
|
{% elif c.status == 'pending' %}
|
||||||
|
<button hx-post="/api/certs/{{ c.id }}/sign/web" hx-target="#sign-msg-{{ c.id }}"
|
||||||
|
class="text-yellow-400 hover:text-yellow-300">Issue</button>
|
||||||
|
<span id="sign-msg-{{ c.id }}" class="ml-2"></span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not certs %}
|
||||||
|
<tr><td colspan="5" class="p-6 text-center text-gray-500">No certificates yet</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
46
api/templates/domains.html
Normal file
46
api/templates/domains.html
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} - Domains{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Domains</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 mb-6">
|
||||||
|
<h2 class="font-bold mb-4">Register New Domain</h2>
|
||||||
|
<form hx-post="/api/domains/web" hx-swap="innerHTML" hx-target="#domain-result"
|
||||||
|
class="flex gap-3 flex-wrap">
|
||||||
|
<input name="name" placeholder="*.example.com" required
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white flex-1 min-w-[200px] focus:outline-none focus:border-blue-500">
|
||||||
|
<input name="description" placeholder="Description (optional)"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white flex-1 min-w-[200px] focus:outline-none focus:border-blue-500">
|
||||||
|
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded font-medium">Register</button>
|
||||||
|
</form>
|
||||||
|
<div id="domain-result" class="mt-3 text-sm"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="border-b border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left p-3 text-gray-400">Domain</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Description</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Status</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in domains %}
|
||||||
|
<tr class="border-b border-gray-700/50">
|
||||||
|
<td class="p-3 font-mono">{{ d.name }}</td>
|
||||||
|
<td class="p-3 text-gray-400">{{ d.description or '-' }}</td>
|
||||||
|
<td class="p-3"><span class="px-2 py-1 rounded text-xs bg-green-900 text-green-300">{{ d.status }}</span></td>
|
||||||
|
<td class="p-3 text-gray-400">{{ d.created_at[:10] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not domains %}
|
||||||
|
<tr><td colspan="4" class="p-6 text-center text-gray-500">No domains registered</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
54
api/templates/history.html
Normal file
54
api/templates/history.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} - History{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Certificate History</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="border-b border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left p-3 text-gray-400">Serial</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Subject</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Domain</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">SANs</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Status</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Issued</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Expires</th>
|
||||||
|
<th class="text-left p-3 text-gray-400">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in certs %}
|
||||||
|
<tr class="border-b border-gray-700/50">
|
||||||
|
<td class="p-3 font-mono text-xs">{{ c.serial or '-' }}</td>
|
||||||
|
<td class="p-3 font-mono">{{ c.subject }}</td>
|
||||||
|
<td class="p-3">{{ c.domain_name or '-' }}</td>
|
||||||
|
<td class="p-3 text-xs text-gray-400">{{ c.san or '-' }}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<span class="px-2 py-1 rounded text-xs {% if c.status == 'issued' %}bg-green-900 text-green-300{% elif c.status == 'pending' %}bg-yellow-900 text-yellow-300{% else %}bg-gray-700{% endif %}">
|
||||||
|
{{ c.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-gray-400">{{ c.issued_at[:10] if c.issued_at else '-' }}</td>
|
||||||
|
<td class="p-3 text-gray-400">{{ c.expires_at[:10] if c.expires_at else '-' }}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
{% if c.status == 'issued' %}
|
||||||
|
<a href="/api/certs/{{ c.id }}/pem" class="text-blue-400 hover:text-blue-300 mr-2">PEM</a>
|
||||||
|
<a href="/api/certs/{{ c.id }}/pfx" class="text-blue-400 hover:text-blue-300">PFX</a>
|
||||||
|
{% elif c.status == 'pending' %}
|
||||||
|
<button hx-post="/api/certs/{{ c.id }}/sign/web" hx-target="#sign-msg-{{ c.id }}"
|
||||||
|
class="text-yellow-400 hover:text-yellow-300">Issue</button>
|
||||||
|
<span id="sign-msg-{{ c.id }}" class="ml-2"></span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not certs %}
|
||||||
|
<tr><td colspan="8" class="p-6 text-center text-gray-500">No certificates yet</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
34
api/templates/login.html
Normal file
34
api/templates/login.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="bg-gray-900 text-gray-100">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CertAuth - Login</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="bg-gray-800 rounded-lg p-8 border border-gray-700 w-full max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold mb-2 text-blue-400">CertAuth</h1>
|
||||||
|
<p class="text-gray-400 mb-6">Certificate Authority Management</p>
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-900/50 border border-red-700 rounded p-3 mb-4 text-red-300 text-sm">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1">Username</label>
|
||||||
|
<input type="text" name="username" required
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1">Password</label>
|
||||||
|
<input type="password" name="password" required
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-2 rounded transition">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
88
api/templates/setup.html
Normal file
88
api/templates/setup.html
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} - Setup{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-bold mb-2">Setup</h1>
|
||||||
|
<p class="text-gray-400 mb-6">Install the CA chain on client machines to trust certificates from this authority.</p>
|
||||||
|
|
||||||
|
<!-- Quick Install -->
|
||||||
|
<div class="bg-blue-900/50 rounded-lg p-6 border border-blue-700 mb-6">
|
||||||
|
<h2 class="font-bold mb-3">Quick Install</h2>
|
||||||
|
<p class="text-sm text-gray-300 mb-3">Run one command on any machine to download and install the CA chain automatically.</p>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-gray-400">Linux / macOS</span>
|
||||||
|
<pre class="bg-gray-900 rounded p-3 text-sm mt-1 overflow-x-auto"><code>curl -sL http://192.168.8.248/setup.sh | sudo bash</code></pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-gray-400">Windows (PowerShell)</span>
|
||||||
|
<pre class="bg-gray-900 rounded p-3 text-sm mt-1 overflow-x-auto"><code>iwr http://192.168.8.248/setup.ps1 -UseBasicParsing | iex</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download CA Chain -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 mb-6">
|
||||||
|
<h2 class="font-bold mb-4">Manual Download</h2>
|
||||||
|
<p class="text-sm text-gray-400 mb-4">Contains the Intermediate + Root CA certificates.</p>
|
||||||
|
<a href="/api/ca-chain" class="inline-block bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded font-medium text-white">Download ca-chain.crt</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform Instructions -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="font-bold text-lg">Manual Installation</h2>
|
||||||
|
|
||||||
|
<!-- Linux Debian/Ubuntu -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="font-bold mb-2">Linux (Debian/Ubuntu)</h3>
|
||||||
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code>sudo cp ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
||||||
|
sudo update-ca-certificates</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Linux Fedora/RHEL -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="font-bold mb-2">Linux (Fedora/RHEL)</h3>
|
||||||
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code># System trust store (curl, openssl)
|
||||||
|
sudo cp ca-chain.crt /etc/pki/ca-trust/source/anchors/certauth.crt
|
||||||
|
sudo update-ca-trust
|
||||||
|
|
||||||
|
# NSS database (Firefox, Thunderbird)
|
||||||
|
sudo certutil -A -n "CertAuth Root CA" -t "CT,Cu,Tu" -d sql:/etc/pki/nssdb/ -i ca-chain.crt</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LibreWolf -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="font-bold mb-2">LibreWolf</h3>
|
||||||
|
<p class="text-sm text-gray-400 mb-2">LibreWolf uses its own NSS database and disables enterprise roots by default. Enable enterprise roots in <code class="bg-gray-700 px-1 rounded">about:config</code> → set <code class="bg-gray-700 px-1 rounded">security.enterprise_roots.enabled</code> to <code class="bg-gray-700 px-1 rounded">true</code>.</p>
|
||||||
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code># Import root CA to your LibreWolf profile
|
||||||
|
certutil -A -n "CertAuth Root CA" -t "CT,Cu,Tu" -d ~/.librewolf/<profile>/ -i ca-chain.crt
|
||||||
|
|
||||||
|
# Clear SSL state cache if you previously got a cert error
|
||||||
|
rm ~/.librewolf/<profile>/SiteSecurityServiceState.bin</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- macOS -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="font-bold mb-2">macOS</h3>
|
||||||
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code>sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca-chain.crt</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Windows -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="font-bold mb-2">Windows</h3>
|
||||||
|
<p class="text-sm text-gray-400 mb-2">Double-click <code class="bg-gray-700 px-1 rounded">ca-chain.crt</code>, then:</p>
|
||||||
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code>1. Click "Install Certificate"
|
||||||
|
2. Select "Local Machine" → Next
|
||||||
|
3. Select "Place all certificates in the following store"
|
||||||
|
4. Browse → "Trusted Root Certification Authorities"
|
||||||
|
5. OK → Next → Finish</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Docker -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="font-bold mb-2">Docker</h3>
|
||||||
|
<pre class="bg-gray-900 rounded p-3 text-sm overflow-x-auto"><code>COPY ca-chain.crt /usr/local/share/ca-certificates/certauth.crt
|
||||||
|
RUN update-ca-certificates</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
16
certauth-api.service
Normal file
16
certauth-api.service
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=CertAuth API
|
||||||
|
After=network.target pcscd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=certauth
|
||||||
|
Group=certauth
|
||||||
|
WorkingDirectory=/opt/certauth/api
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/usr/bin/python3 -m uvicorn main:app --host 127.0.0.1 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
38
landing/index.html
Normal file
38
landing/index.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Home Network</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #0f172a; --card: #1e293b; --text: #f8fafc; --accent: #38bdf8; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
h1 { text-align: center; margin-bottom: 3rem; font-size: 2.5rem; background: linear-gradient(135deg, var(--accent), #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
|
||||||
|
.card { background: var(--card); border-radius: 12px; padding: 1.5rem; text-decoration: none; color: var(--text); transition: all 0.2s; border: 1px solid rgba(255,255,255,0.1); }
|
||||||
|
.card:hover { transform: translateY(-2px); border-color: var(--accent); box-shadow: 0 4px 12px rgba(56, 189, 248, 0.1); }
|
||||||
|
.card h2 { margin: 0 0 0.5rem; font-size: 1.25rem; }
|
||||||
|
.card p { margin: 0; color: #94a3b8; font-size: 0.9rem; }
|
||||||
|
.icon { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Home Network</h1>
|
||||||
|
<div class="grid">
|
||||||
|
<a href="https://tv.example.com" class="card"><div class="icon">🎬</div><h2>TV & Media</h2><p>Jellyfin media server</p></a>
|
||||||
|
<a href="https://ai.example.com" class="card"><div class="icon">🤖</div><h2>AI Chat</h2><p>Open WebUI</p></a>
|
||||||
|
<a href="https://agents.example.com" class="card"><div class="icon">⚡</div><h2>Automation</h2><p>n8n workflows</p></a>
|
||||||
|
<a href="https://papers.example.com" class="card"><div class="icon">📄</div><h2>Research Papers</h2><p>Arxiv browser</p></a>
|
||||||
|
<a href="https://pin.example.com" class="card"><div class="icon">📌</div><h2>Bookmarks</h2><p>Karakeep</p></a>
|
||||||
|
<a href="https://clip.example.com" class="card"><div class="icon">🎞️</div><h2>Clips</h2><p>Jelly Clip UI</p></a>
|
||||||
|
<a href="https://paste.example.com" class="card"><div class="icon">📋</div><h2>PasteBin</h2><p>Text sharing</p></a>
|
||||||
|
<a href="https://llm.example.com" class="card"><div class="icon">🧠</div><h2>LLM Proxy</h2><p>LiteLLM</p></a>
|
||||||
|
<a href="https://monitor.example.com" class="card"><div class="icon">📊</div><h2>Monitoring</h2><p>Grafana</p></a>
|
||||||
|
<a href="https://stocks.example.com" class="card"><div class="icon">📈</div><h2>Stock Docs</h2><p>Financial docs</p></a>
|
||||||
|
<a href="https://stockmcp.example.com" class="card"><div class="icon">💹</div><h2>Stock MCP</h2><p>Market data API</p></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
landing/playground.ms.html
Normal file
34
landing/playground.ms.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Playground</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #0f172a; --card: #1e293b; --text: #f8fafc; --accent: #38bdf8; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
h1 { text-align: center; margin-bottom: 3rem; font-size: 2.5rem; background: linear-gradient(135deg, var(--accent), #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
|
||||||
|
.card { background: var(--card); border-radius: 12px; padding: 1.5rem; text-decoration: none; color: var(--text); transition: all 0.2s; border: 1px solid rgba(255,255,255,0.1); }
|
||||||
|
.card:hover { transform: translateY(-2px); border-color: var(--accent); box-shadow: 0 4px 12px rgba(56, 189, 248, 0.1); }
|
||||||
|
.card h2 { margin: 0 0 0.5rem; font-size: 1.25rem; }
|
||||||
|
.card p { margin: 0; color: #94a3b8; font-size: 0.9rem; }
|
||||||
|
.icon { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Playground</h1>
|
||||||
|
<div class="grid">
|
||||||
|
<a href="https://pinvault.example.com" class="card"><div class="icon">🔐</div><h2>PinVault</h2><p>Password manager</p></a>
|
||||||
|
<a href="https://youtube.example.com" class="card"><div class="icon">📺</div><h2>YouTube</h2><p>YouTube CLI</p></a>
|
||||||
|
<a href="https://archive.example.com" class="card"><div class="icon">🏛️</div><h2>Archive</h2><p>Website archiving</p></a>
|
||||||
|
<a href="https://vote.example.com" class="card"><div class="icon">🗳️</div><h2>Vote</h2><p>Voting app</p></a>
|
||||||
|
<a href="https://search.example.com" class="card"><div class="icon">🔍</div><h2>Search</h2><p>Privacy metasearch</p></a>
|
||||||
|
<a href="https://paste.example.com" class="card"><div class="icon">📋</div><h2>Paste</h2><p>Pastebin service</p></a>
|
||||||
|
<a href="https://ai.example.com" class="card"><div class="icon">🤖</div><h2>AI</h2><p>Local LLM</p></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
744
nginx-local.conf
Normal file
744
nginx-local.conf
Normal file
@ -0,0 +1,744 @@
|
|||||||
|
# ============================================================
|
||||||
|
# example.com - Main landing page (HTTP only, no cert for bare domain)
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name example.com;
|
||||||
|
|
||||||
|
root /var/www/example.com;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# web.example.com - HTTPS landing page
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name web.example.com www.web.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/web.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/web.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
root /var/www/example.com;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP -> HTTPS redirect for web.example.com
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name web.example.com www.web.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# tv.example.com - Jellyfin
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name tv.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/tv.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/tv.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8096;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name tv.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ai.example.com - Open WebUI
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name ai.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/ai.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/ai.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
client_max_body_size 20m;
|
||||||
|
gzip off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ai.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# agents.example.com - n8n
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name agents.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/agents.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/agents.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:5678;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
gzip off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name agents.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# papers.example.com - Arxiv Sanity Lite
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name papers.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/papers.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/papers.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:7800;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name papers.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# pin.example.com - Karakeep
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name pin.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/pin.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/pin.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:4444;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name pin.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# clip.example.com - Jelly Clip
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name clip.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/clip.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/clip.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:5001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name clip.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# paste.example.com - Pastebin
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name paste.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/paste.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/paste.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:9780;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name paste.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# llm.example.com - LiteLLM Proxy
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name llm.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/llm.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/llm.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:4000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name llm.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# monitor.example.com - Grafana
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name monitor.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/monitor.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/monitor.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3003;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name monitor.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# stocks.example.com - Stock Docs Article Server
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name stocks.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/stocks.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/stocks.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:5008;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name stocks.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# stockmcp.example.com - Stock MCP
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name stockmcp.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/stockmcp.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/stockmcp.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:5005;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name stockmcp.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# play.example.com - (placeholder - specify backend port)
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name play.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/play.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/play.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 503 "Service not configured - set backend port";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name play.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# git.example.com - Gitea (HTTPS)
|
||||||
|
# ============================================================
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name git.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/git.example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/git.example.com.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://192.168.8.192:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name git.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# books.example.com - Bookworm
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name books.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:1333;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ai-host.example.com - Ollama
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name ai-host.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://192.168.8.124:11434;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# chat.example.com - Element/Matrix UI
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name chat.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8009;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# conduit.example.com - Matrix Server
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name conduit.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8448;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# tiktok.example.com
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name tiktok.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://0.0.0.0:4321;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# archive.example.com - ArchiveBox
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name archive.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:1234;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# loop.example.com - Karakeep Chrome extension
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name loop.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3010;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# board.example.com - Karakeep board
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name board.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# substack.example.com - Static Substack pages
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name substack.example.com;
|
||||||
|
|
||||||
|
root /mnt/centralstoragemedia/Websites/Substack/substack_html_pages;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /substack_html_pages/ {
|
||||||
|
alias /mnt/centralstoragemedia/Websites/Substack/substack_html_pages/;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /economics/ {
|
||||||
|
alias /mnt/centralstoragemedia/Websites/Substack/substack_html_pages/paulkrugman/;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
autoindex off;
|
||||||
|
|
||||||
|
location = /ai {
|
||||||
|
rewrite ^ /natesnewsletter.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /economics {
|
||||||
|
rewrite ^ /paulkrugman.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /games {
|
||||||
|
rewrite ^ /theshortcut.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 404 /404.html;
|
||||||
|
location = /404.html {
|
||||||
|
internal;
|
||||||
|
root /etc/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/js/ {
|
||||||
|
alias /mnt/centralstoragemedia/Websites/Substack/assets/js/;
|
||||||
|
default_type application/javascript;
|
||||||
|
expires 30d;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/css/ {
|
||||||
|
alias /mnt/centralstoragemedia/Websites/Substack/assets/css/;
|
||||||
|
default_type text/css;
|
||||||
|
expires 30d;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /var/log/nginx/substack.access.log;
|
||||||
|
error_log /var/log/nginx/substack.error.log;
|
||||||
|
}
|
||||||
|
|
||||||
|
# kiwix.example.com - Wikipedia/Kiwix
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name kiwix.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8008;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# archive2.example.com - ArchiveBox snapshots
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name archive2.example.com;
|
||||||
|
|
||||||
|
root /mnt/centralstoragemedia/Websites/ArchiveBox/data/archive;
|
||||||
|
index singlefile.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri/singlefile.html $uri/index.html $uri/output.pdf =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/([^/]+)/pdf$ {
|
||||||
|
try_files $uri /$1/output.pdf =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/([^/]+)/html$ {
|
||||||
|
try_files $uri /$1/output.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/([^/]+)/singlefile$ {
|
||||||
|
try_files $uri /$1/singlefile.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
autoindex off;
|
||||||
|
|
||||||
|
error_page 404 /404.html;
|
||||||
|
location = /404.html {
|
||||||
|
internal;
|
||||||
|
root /etc/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /var/log/nginx/archive2.access.log;
|
||||||
|
error_log /var/log/nginx/archive2.error.log;
|
||||||
|
}
|
||||||
|
|
||||||
|
# red.example.com - Reddit
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name red.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:6006;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# space.example.com - NASA
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name space.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:6008;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# insights.example.com
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name insights.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:5010;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# podcast.example.com - Static podcast files
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name podcast.example.com;
|
||||||
|
|
||||||
|
root /mnt/centralstoragemedia/Podcasts;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri.wav =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.wav$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
1746
setup-certauth.sh
Normal file
1746
setup-certauth.sh
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user