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