first rewrite commit

This commit is contained in:
fuomag9
2025-10-31 20:08:28 +01:00
parent 85d3917f08
commit 315192fb54
605 changed files with 10913 additions and 47794 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"extends": ["next/core-web-vitals"],
"rules": {
"@next/next/no-img-element": "off"
}
}
+8 -9
View File
@@ -1,9 +1,8 @@
.DS_Store
.idea
._*
.vscode
certbot-help.txt
test/node_modules
*/node_modules
docker/dev/dnsrouter-config.json.tmp
docker/dev/resolv.conf
node_modules
.next
out
dist
data
*.log
.env*
/.idea
Vendored
-285
View File
@@ -1,285 +0,0 @@
import groovy.transform.Field
@Field
def shOutput = ""
def buildxPushTags = ""
pipeline {
agent {
label 'docker-multiarch'
}
options {
buildDiscarder(logRotator(numToKeepStr: '5'))
disableConcurrentBuilds()
ansiColor('xterm')
}
environment {
IMAGE = 'nginx-proxy-manager'
BUILD_VERSION = getVersion()
MAJOR_VERSION = '2'
BRANCH_LOWER = "${BRANCH_NAME.toLowerCase().replaceAll('\\\\', '-').replaceAll('/', '-').replaceAll('\\.', '-')}"
BUILDX_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}"
COMPOSE_INTERACTIVE_NO_CLI = 1
}
stages {
stage('Environment') {
parallel {
stage('Master') {
when {
branch 'master'
}
steps {
script {
buildxPushTags = "-t docker.io/jc21/${IMAGE}:${BUILD_VERSION} -t docker.io/jc21/${IMAGE}:${MAJOR_VERSION} -t docker.io/jc21/${IMAGE}:latest"
}
}
}
stage('Other') {
when {
not {
branch 'master'
}
}
steps {
script {
// Defaults to the Branch name, which is applies to all branches AND pr's
buildxPushTags = "-t docker.io/nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}"
}
}
}
stage('Versions') {
steps {
sh 'cat frontend/package.json | jq --arg BUILD_VERSION "${BUILD_VERSION}" \'.version = $BUILD_VERSION\' | sponge frontend/package.json'
sh 'echo -e "\\E[1;36mFrontend Version is:\\E[1;33m $(cat frontend/package.json | jq -r .version)\\E[0m"'
sh 'cat backend/package.json | jq --arg BUILD_VERSION "${BUILD_VERSION}" \'.version = $BUILD_VERSION\' | sponge backend/package.json'
sh 'echo -e "\\E[1;36mBackend Version is:\\E[1;33m $(cat backend/package.json | jq -r .version)\\E[0m"'
sh 'sed -i -E "s/(version-)[0-9]+\\.[0-9]+\\.[0-9]+(-green)/\\1${BUILD_VERSION}\\2/" README.md'
}
}
stage('Docker Login') {
steps {
withCredentials([usernamePassword(credentialsId: 'jc21-dockerhub', passwordVariable: 'dpass', usernameVariable: 'duser')]) {
sh 'docker login -u "${duser}" -p "${dpass}"'
}
}
}
}
}
stage('Builds') {
parallel {
stage('Project') {
steps {
script {
// Frontend and Backend
def shStatusCode = sh(label: 'Checking and Building', returnStatus: true, script: '''
set -e
./scripts/ci/frontend-build > ${WORKSPACE}/tmp-sh-build 2>&1
./scripts/ci/test-and-build > ${WORKSPACE}/tmp-sh-build 2>&1
''')
shOutput = readFile "${env.WORKSPACE}/tmp-sh-build"
if (shStatusCode != 0) {
error "${shOutput}"
}
}
}
post {
always {
sh 'rm -f ${WORKSPACE}/tmp-sh-build'
}
failure {
npmGithubPrComment("CI Error:\n\n```\n${shOutput}\n```", true)
}
}
}
stage('Docs') {
steps {
dir(path: 'docs') {
sh 'yarn install'
sh 'yarn build'
}
}
}
}
}
stage('Test Sqlite') {
environment {
COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_sqlite"
COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.sqlite.yml'
}
when {
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
steps {
sh 'rm -rf ./test/results/junit/*'
sh './scripts/ci/fulltest-cypress'
}
post {
always {
// Dumps to analyze later
sh 'mkdir -p debug/sqlite'
sh 'docker logs $(docker-compose ps --all -q fullstack) > debug/sqlite/docker_fullstack.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q stepca) > debug/sqlite/docker_stepca.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q pdns) > debug/sqlite/docker_pdns.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q pdns-db) > debug/sqlite/docker_pdns-db.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q dnsrouter) > debug/sqlite/docker_dnsrouter.log 2>&1'
junit 'test/results/junit/*'
sh 'docker-compose down --remove-orphans --volumes -t 30 || true'
}
unstable {
dir(path: 'test/results') {
archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml')
}
}
}
}
stage('Test Mysql') {
environment {
COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_mysql"
COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.mysql.yml'
}
when {
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
steps {
sh 'rm -rf ./test/results/junit/*'
sh './scripts/ci/fulltest-cypress'
}
post {
always {
// Dumps to analyze later
sh 'mkdir -p debug/mysql'
sh 'docker logs $(docker-compose ps --all -q fullstack) > debug/mysql/docker_fullstack.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q stepca) > debug/mysql/docker_stepca.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q pdns) > debug/mysql/docker_pdns.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q pdns-db) > debug/mysql/docker_pdns-db.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q dnsrouter) > debug/mysql/docker_dnsrouter.log 2>&1'
junit 'test/results/junit/*'
sh 'docker-compose down --remove-orphans --volumes -t 30 || true'
}
unstable {
dir(path: 'test/results') {
archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml')
}
}
}
}
stage('Test Postgres') {
environment {
COMPOSE_PROJECT_NAME = "npm_${BRANCH_LOWER}_${BUILD_NUMBER}_postgres"
COMPOSE_FILE = 'docker/docker-compose.ci.yml:docker/docker-compose.ci.postgres.yml'
}
when {
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
steps {
sh 'rm -rf ./test/results/junit/*'
sh './scripts/ci/fulltest-cypress'
}
post {
always {
// Dumps to analyze later
sh 'mkdir -p debug/postgres'
sh 'docker logs $(docker-compose ps --all -q fullstack) > debug/postgres/docker_fullstack.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q stepca) > debug/postgres/docker_stepca.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q pdns) > debug/postgres/docker_pdns.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q pdns-db) > debug/postgres/docker_pdns-db.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q dnsrouter) > debug/postgres/docker_dnsrouter.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q db-postgres) > debug/postgres/docker_db-postgres.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q authentik) > debug/postgres/docker_authentik.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q authentik-redis) > debug/postgres/docker_authentik-redis.log 2>&1'
sh 'docker logs $(docker-compose ps --all -q authentik-ldap) > debug/postgres/docker_authentik-ldap.log 2>&1'
junit 'test/results/junit/*'
sh 'docker-compose down --remove-orphans --volumes -t 30 || true'
}
unstable {
dir(path: 'test/results') {
archiveArtifacts(allowEmptyArchive: true, artifacts: '**/*', excludes: '**/*.xml')
}
}
}
}
stage('MultiArch Build') {
when {
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
steps {
sh "./scripts/buildx --push ${buildxPushTags}"
}
}
stage('Docs / Comment') {
parallel {
stage('Docs Job') {
when {
allOf {
branch pattern: "^(develop|master)\$", comparator: "REGEXP"
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
}
steps {
build wait: false, job: 'nginx-proxy-manager-docs', parameters: [string(name: 'docs_branch', value: "$BRANCH_NAME")]
}
}
stage('PR Comment') {
when {
allOf {
changeRequest()
not {
equals expected: 'UNSTABLE', actual: currentBuild.result
}
}
}
steps {
script {
npmGithubPrComment("""Docker Image for build ${BUILD_NUMBER} is available on [DockerHub](https://cloud.docker.com/repository/docker/nginxproxymanager/${IMAGE}-dev):
```
nginxproxymanager/${IMAGE}-dev:${BRANCH_LOWER}
```
> [!NOTE]
> Ensure you backup your NPM instance before testing this image! Especially if there are database changes.
> This is a different docker image namespace than the official image.
> [!WARNING]
> Changes and additions to DNS Providers require verification by at least 2 members of the community!
""", true)
}
}
}
}
}
}
post {
always {
sh 'echo Reverting ownership'
sh 'docker run --rm -v "$(pwd):/data" jc21/ci-tools chown -R "$(id -u):$(id -g)" /data'
printResult(true)
}
failure {
archiveArtifacts(artifacts: 'debug/**/*.*', allowEmptyArchive: true)
}
unstable {
archiveArtifacts(artifacts: 'debug/**/*.*', allowEmptyArchive: true)
}
}
}
def getVersion() {
ver = sh(script: 'cat .version', returnStdout: true)
return ver.trim()
}
def getCommit() {
ver = sh(script: 'git log -n 1 --format=%h', returnStdout: true)
return ver.trim()
}
+92 -98
View File
@@ -1,120 +1,114 @@
<p align="center">
<img src="https://nginxproxymanager.com/github.png">
<br><br>
<img src="https://img.shields.io/badge/version-2.12.6-green.svg?style=for-the-badge">
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
</a>
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
<img src="https://img.shields.io/docker/pulls/jc21/nginx-proxy-manager.svg?style=for-the-badge">
</a>
</p>
# Caddy Proxy Manager
This project comes as a pre-built docker image that enables you to easily forward to your websites
running at home or otherwise, including free SSL, without having to know too much about Nginx or Letsencrypt.
Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse proxy configuration, TLS automation, access control, and observability. The entire application is built with Next.js and ships with a lean dependency set, OAuth2 login, and a battery of tools for managing hosts, redirects, streams, certificates, and Cloudflare DNS-based certificate issuance.
- [Quick Setup](#quick-setup)
- [Full Setup](https://nginxproxymanager.com/setup/)
- [Screenshots](https://nginxproxymanager.com/screenshots/)
## Highlights
## Project Goal
- **Next.js 14 App Router** UI and API in a single project, backed by an embedded SQLite database.
- **OAuth2 single sign-on** with PKCE and configurable claim mapping. The first authenticated user becomes the administrator.
- **End-to-end Caddy orchestration** using the admin API, generating JSON configurations for HTTP, HTTPS, redirects, custom 404 hosts, and TCP/UDP streams.
- **Cloudflare DNS challenge integration** via xcaddy-built Caddy binary with `cloudflare` and `layer4` modules; credentials are stored in the UI.
- **Access lists** (HTTP basic auth), custom certificates (managed or imported PEM), and a full audit log of administrative changes.
- **Default HSTS configuration** (`Strict-Transport-Security: max-age=63072000`) baked into every HTTP route to meet security baseline requirements.
I created this project to fill a personal need to provide users with an easy way to accomplish reverse
proxying hosts with SSL termination and it had to be so easy that a monkey could do it. This goal hasn't changed.
While there might be advanced options they are optional and the project should be as simple as possible
so that the barrier for entry here is low.
## Project Structure
<a href="https://www.buymeacoffee.com/jc21" target="_blank"><img src="http://public.jc21.com/github/by-me-a-coffee.png" alt="Buy Me A Coffee" style="height: 51px !important;width: 217px !important;" ></a>
## Features
- Beautiful and Secure Admin Interface based on [Tabler](https://tabler.github.io/)
- Easily create forwarding domains, redirections, streams and 404 hosts without knowing anything about Nginx
- Free SSL using Let's Encrypt or provide your own custom SSL certificates
- Access Lists and basic HTTP Authentication for your hosts
- Advanced Nginx configuration available for super users
- User management, permissions and audit log
## Hosting your home network
I won't go in to too much detail here but here are the basics for someone new to this self-hosted world.
1. Your home router will have a Port Forwarding section somewhere. Log in and find it
2. Add port forwarding for port 80 and 443 to the server hosting this project
3. Configure your domain name details to point to your home, either with a static ip or a service like DuckDNS or [Amazon Route53](https://github.com/jc21/route53-ddns)
4. Use the Nginx Proxy Manager as your gateway to forward to your other web based services
## Quick Setup
1. Install Docker and Docker-Compose
- [Docker Install documentation](https://docs.docker.com/install/)
- [Docker-Compose Install documentation](https://docs.docker.com/compose/install/)
2. Create a docker-compose.yml file similar to this:
```yml
services:
app:
image: 'docker.io/jc21/nginx-proxy-manager:latest'
restart: unless-stopped
ports:
- '80:80'
- '81:81'
- '443:443'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
```
.
├── app/ # Next.js app router (auth, dashboard, APIs)
├── src/
│ └── lib/ # Database, Caddy integration, models, settings
├── docker/ # Dockerfiles for web + Caddy
├── compose.yaml # Production-ready docker compose definition
└── data/ # (Generated) SQLite database, TLS material, Caddy data
```
This is the bare minimum configuration required. See the [documentation](https://nginxproxymanager.com/setup/) for more.
## Requirements
3. Bring up your stack by running
- Node.js 20+ (development)
- Docker + Docker Compose v2 (deployment)
- OAuth2 identity provider (OIDC compliant preferred)
- Optional: Cloudflare DNS API token for automated certificate issuance
## Quick Start
1. **Install dependencies**
```bash
npm install
```
> Package downloads require network access.
2. **Run the development server**
```bash
npm run dev
```
3. **Configure OAuth2**
- Visit `http://localhost:3000/setup/oauth`.
- Supply your identity providers authorization, token, and userinfo endpoints plus client credentials.
- Sign in; the first user becomes an administrator.
4. **Configure Cloudflare DNS (optional)**
- Navigate to **Settings → Cloudflare DNS**.
- Provide an API token with `Zone.DNS:Edit` scope and the relevant zone/account IDs.
- Any managed certificates attached to hosts will now request TLS via DNS validation.
## Docker Compose
`compose.yaml` defines a two-container stack:
- `app`: Next.js server with SQLite database and certificate store in `/data`.
- `caddy`: xcaddy-built binary with Cloudflare DNS provider and layer4 modules. The default configuration responds on `caddyproxymanager.com` and serves the required HSTS header:
```caddyfile
caddyproxymanager.com {
header Strict-Transport-Security "max-age=63072000"
respond "Caddy Proxy Manager is running" 200
}
```
Launch the stack:
```bash
docker-compose up -d
# If using docker-compose-plugin
docker compose up -d
```
4. Log in to the Admin UI
Environment variables:
When your docker container is running, connect to it on port `81` for the admin interface.
Sometimes this can take a little bit because of the entropy of keys.
- `SESSION_SECRET`: random 32+ character string used to sign session cookies.
- `DATABASE_PATH`: path to the SQLite database (default `/data/app/app.db` in containers).
- `CERTS_DIRECTORY`: directory for imported PEM files shared with the Caddy container.
- `CADDY_API_URL`: URL for the Caddy admin API (default `http://caddy:2019` inside the compose network).
- `PRIMARY_DOMAIN`: default domain served by the bootstrap Caddyfile (defaults to `caddyproxymanager.com`).
[http://127.0.0.1:81](http://127.0.0.1:81)
## Data Locations
Default Admin User:
```
Email: admin@example.com
Password: changeme
```
- `data/app/app.db`: SQLite database storing configuration, sessions, and audit log.
- `data/certs/`: Imported TLS certificates and keys generated by the UI.
- `data/caddy/`: Autogenerated Caddy state (ACME storage, etc.).
Immediately after logging in with this default user you will be asked to modify your details and change your password.
## UI Features
- **Proxy Hosts:** HTTP(S) reverse proxies with HSTS, access lists, optional custom certificates, and WebSocket support.
- **Redirects:** 301/302 responses with optional path/query preservation.
- **Dead Hosts:** Branded responses for offline services.
- **Streams:** TCP/UDP forwarding powered by the Caddy layer4 module.
- **Access Lists:** Bcrypt-backed basic auth credentials, assignable to proxy hosts.
- **Certificates:** Managed (ACME) or imported PEM certificates with audit history.
- **Audit Log:** Chronological record of every configuration change and actor.
- **Settings:** General metadata, OAuth2 endpoints, and Cloudflare DNS credentials.
## Contributing
## Development Notes
All are welcome to create pull requests for this project, against the `develop` branch. Official releases are created from the `master` branch.
- SQLite schema migrations are embedded in `src/lib/migrations.ts` and run automatically on startup.
- Caddy configuration is rebuilt on every change and pushed via the admin API. Failures are surfaced to the UI.
- OAuth2 login uses PKCE and stores session tokens as HMAC-signed cookies backed by the database.
CI is used in this project. All PR's must pass before being considered. After passing,
docker builds for PR's are available on dockerhub for manual verifications.
## License
Documentation within the `develop` branch is available for preview at
[https://develop.nginxproxymanager.com](https://develop.nginxproxymanager.com)
### Contributors
Special thanks to [all of our contributors](https://github.com/NginxProxyManager/nginx-proxy-manager/graphs/contributors).
## Getting Support
1. [Found a bug?](https://github.com/NginxProxyManager/nginx-proxy-manager/issues)
2. [Discussions](https://github.com/NginxProxyManager/nginx-proxy-manager/discussions)
3. [Reddit](https://reddit.com/r/nginxproxymanager)
MIT License © Caddy Proxy Manager contributors.
+85
View File
@@ -0,0 +1,85 @@
import { redirect } from "next/navigation";
import { getSession } from "@/src/lib/auth/session";
import { buildAuthorizationUrl } from "@/src/lib/auth/oauth";
import { getOAuthSettings } from "@/src/lib/settings";
export default async function LoginPage() {
const session = getSession();
if (session) {
redirect("/");
}
const oauthConfigured = Boolean(getOAuthSettings());
async function startOAuth() {
"use server";
const target = buildAuthorizationUrl("/");
redirect(target);
}
return (
<div className="auth-wrapper">
<h1>Caddy Proxy Manager</h1>
<p>Sign in with your organization&apos;s OAuth2 provider to continue.</p>
{oauthConfigured ? (
<form action={startOAuth}>
<button type="submit" className="primary">
Sign in with OAuth2
</button>
</form>
) : (
<div className="notice">
<p>
The system administrator needs to configure OAuth2 settings before logins are allowed. If this is a fresh installation, start
with the{" "}
<a href="/setup/oauth">
OAuth setup wizard
</a>
.
</p>
</div>
)}
<style jsx>{`
.auth-wrapper {
max-width: 420px;
margin: 20vh auto;
padding: 3rem;
background: rgba(10, 17, 28, 0.92);
border-radius: 16px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
text-align: center;
}
h1 {
margin: 0 0 1rem;
}
p {
margin: 0 0 1.5rem;
color: rgba(255, 255, 255, 0.75);
}
.primary {
padding: 0.75rem 1.5rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.primary:hover {
opacity: 0.9;
}
.notice {
margin-top: 1.5rem;
padding: 1rem;
border-radius: 12px;
background: rgba(255, 193, 7, 0.12);
color: #ffc107;
font-weight: 500;
}
.notice a {
color: #fff;
}
`}</style>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import {
addAccessListEntry,
createAccessList,
deleteAccessList,
removeAccessListEntry,
updateAccessList
} from "@/src/lib/models/access-lists";
export async function createAccessListAction(formData: FormData) {
const { user } = requireUser();
const rawUsers = String(formData.get("users") ?? "");
const accounts = rawUsers
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [username, password] = line.split(":");
return { username: username.trim(), password: (password ?? "").trim() };
})
.filter((item) => item.username && item.password);
await createAccessList(
{
name: String(formData.get("name") ?? "Access list"),
description: formData.get("description") ? String(formData.get("description")) : null,
users: accounts
},
user.id
);
revalidatePath("/access-lists");
}
export async function updateAccessListAction(id: number, formData: FormData) {
const { user } = requireUser();
await updateAccessList(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
description: formData.get("description") ? String(formData.get("description")) : undefined
},
user.id
);
revalidatePath("/access-lists");
}
export async function deleteAccessListAction(id: number) {
const { user } = requireUser();
await deleteAccessList(id, user.id);
revalidatePath("/access-lists");
}
export async function addAccessEntryAction(id: number, formData: FormData) {
const { user } = requireUser();
await addAccessListEntry(
id,
{
username: String(formData.get("username") ?? ""),
password: String(formData.get("password") ?? "")
},
user.id
);
revalidatePath("/access-lists");
}
export async function deleteAccessEntryAction(accessListId: number, entryId: number) {
const { user } = requireUser();
await removeAccessListEntry(accessListId, entryId, user.id);
revalidatePath("/access-lists");
}
+207
View File
@@ -0,0 +1,207 @@
import { listAccessLists } from "@/src/lib/models/access-lists";
import { addAccessEntryAction, createAccessListAction, deleteAccessEntryAction, deleteAccessListAction, updateAccessListAction } from "./actions";
export default function AccessListsPage() {
const lists = listAccessLists();
return (
<div className="page">
<header>
<h1>Access Lists</h1>
<p>Protect proxy hosts with HTTP basic authentication credentials.</p>
</header>
<section className="grid">
{lists.map((list) => (
<div className="card" key={list.id}>
<form action={(formData) => updateAccessListAction(list.id, formData)} className="header">
<div>
<input name="name" defaultValue={list.name} />
<textarea name="description" defaultValue={list.description ?? ""} rows={2} placeholder="Description" />
</div>
<button type="submit" className="primary small">
Save
</button>
</form>
<div className="entries">
<h3>Accounts</h3>
{list.entries.length === 0 ? (
<p className="empty">No credentials configured.</p>
) : (
<ul>
{list.entries.map((entry) => (
<li key={entry.id}>
<span>{entry.username}</span>
<form action={() => deleteAccessEntryAction(list.id, entry.id)}>
<button type="submit" className="ghost">
Remove
</button>
</form>
</li>
))}
</ul>
)}
</div>
<form action={(formData) => addAccessEntryAction(list.id, formData)} className="add-entry">
<input name="username" placeholder="Username" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit" className="primary small">
Add
</button>
</form>
<form action={() => deleteAccessListAction(list.id)}>
<button type="submit" className="danger">
Delete list
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create access list</h2>
<form action={createAccessListAction} className="form">
<label>
Name
<input name="name" placeholder="Internal users" required />
</label>
<label>
Description
<textarea name="description" placeholder="Optional description" rows={2} />
</label>
<label>
Seed members (one per line, username:password)
<textarea name="users" placeholder="alice:password123" rows={4} />
</label>
<div className="actions">
<button type="submit" className="primary">
Create Access List
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.header input,
.header textarea {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.entries ul {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.entries li {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(8, 12, 20, 0.9);
border-radius: 10px;
padding: 0.6rem 0.8rem;
}
.entries span {
font-weight: 500;
}
.empty {
color: rgba(255, 255, 255, 0.5);
}
.add-entry {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.6rem;
}
.add-entry input {
padding: 0.6rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.primary {
padding: 0.6rem 1.3rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.primary.small {
padding: 0.45rem 1rem;
}
.ghost {
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
align-self: flex-start;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.form input,
.form textarea {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.actions {
display: flex;
justify-content: flex-end;
}
`}</style>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { listAuditEvents } from "@/src/lib/models/audit";
import { listUsers } from "@/src/lib/models/user";
export default function AuditLogPage() {
const events = listAuditEvents(200);
const users = new Map(listUsers().map((user) => [user.id, user]));
return (
<div className="page">
<header>
<h1>Audit Log</h1>
<p>Review configuration changes and user activity.</p>
</header>
<table>
<thead>
<tr>
<th>When</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{events.map((event) => {
const user = event.user_id ? users.get(event.user_id) : null;
return (
<tr key={event.id}>
<td>{new Date(event.created_at).toLocaleString()}</td>
<td>{user ? user.name ?? user.email : "System"}</td>
<td>{event.action}</td>
<td>{event.entity_type}</td>
<td>{event.summary ?? "—"}</td>
</tr>
);
})}
</tbody>
</table>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
table {
width: 100%;
border-collapse: collapse;
border-radius: 16px;
overflow: hidden;
}
thead {
background: rgba(16, 24, 38, 0.95);
}
th,
td {
padding: 0.9rem 1.1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
tbody tr:nth-child(even) {
background: rgba(8, 12, 20, 0.9);
}
tbody tr:hover {
background: rgba(0, 114, 255, 0.1);
}
`}</style>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { createCertificate, deleteCertificate, updateCertificate } from "@/src/lib/models/certificates";
function parseDomains(value: FormDataEntryValue | null): string[] {
if (!value || typeof value !== "string") {
return [];
}
return value
.replace(/\n/g, ",")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export async function createCertificateAction(formData: FormData) {
const { user } = requireUser();
const type = String(formData.get("type") ?? "managed") as "managed" | "imported";
await createCertificate(
{
name: String(formData.get("name") ?? "Certificate"),
type,
domain_names: parseDomains(formData.get("domain_names")),
auto_renew: type === "managed" ? formData.get("auto_renew") === "on" : false,
certificate_pem: type === "imported" ? String(formData.get("certificate_pem") ?? "") : null,
private_key_pem: type === "imported" ? String(formData.get("private_key_pem") ?? "") : null
},
user.id
);
revalidatePath("/certificates");
}
export async function updateCertificateAction(id: number, formData: FormData) {
const { user } = requireUser();
const type = formData.get("type") ? (String(formData.get("type")) as "managed" | "imported") : undefined;
await updateCertificate(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
type,
domain_names: formData.get("domain_names") ? parseDomains(formData.get("domain_names")) : undefined,
auto_renew: formData.has("auto_renew_present") ? formData.get("auto_renew") === "on" : undefined,
certificate_pem: formData.get("certificate_pem") ? String(formData.get("certificate_pem")) : undefined,
private_key_pem: formData.get("private_key_pem") ? String(formData.get("private_key_pem")) : undefined
},
user.id
);
revalidatePath("/certificates");
}
export async function deleteCertificateAction(id: number) {
const { user } = requireUser();
await deleteCertificate(id, user.id);
revalidatePath("/certificates");
}
+220
View File
@@ -0,0 +1,220 @@
import { listCertificates } from "@/src/lib/models/certificates";
import { createCertificateAction, deleteCertificateAction, updateCertificateAction } from "./actions";
export default function CertificatesPage() {
const certificates = listCertificates();
return (
<div className="page">
<header>
<h1>Certificates</h1>
<p>Manage ACME-managed certificates or import your own PEM files for custom deployments.</p>
</header>
<section className="grid">
{certificates.map((cert) => (
<div className="card" key={cert.id}>
<details>
<summary>
<div className="summary">
<div>
<h2>{cert.name}</h2>
<p>{cert.domain_names.join(", ")}</p>
</div>
<span className="badge">{cert.type === "managed" ? "Managed" : "Imported"}</span>
</div>
</summary>
<form action={(formData) => updateCertificateAction(cert.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={cert.name} />
</label>
<label>
Domains
<textarea name="domain_names" defaultValue={cert.domain_names.join("\n")} rows={3} />
</label>
<label>
Type
<select name="type" defaultValue={cert.type}>
<option value="managed">Managed (ACME)</option>
<option value="imported">Imported</option>
</select>
</label>
{cert.type === "managed" ? (
<label className="toggle">
<input type="hidden" name="auto_renew_present" value="1" />
<input type="checkbox" name="auto_renew" defaultChecked={cert.auto_renew} /> Auto renew
</label>
) : (
<>
<label>
Certificate PEM
<textarea name="certificate_pem" placeholder="-----BEGIN CERTIFICATE-----" rows={6} />
</label>
<label>
Private key PEM
<textarea name="private_key_pem" placeholder="-----BEGIN PRIVATE KEY-----" rows={6} />
</label>
</>
)}
<div className="actions">
<button type="submit" className="primary">
Save certificate
</button>
</div>
</form>
</details>
<form action={() => deleteCertificateAction(cert.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create certificate</h2>
<form action={createCertificateAction} className="form">
<label>
Name
<input name="name" placeholder="Wildcard certificate" required />
</label>
<label>
Domains
<textarea name="domain_names" placeholder="example.com" rows={3} required />
</label>
<label>
Type
<select name="type" defaultValue="managed">
<option value="managed">Managed (ACME)</option>
<option value="imported">Imported</option>
</select>
</label>
<label className="toggle">
<input type="checkbox" name="auto_renew" defaultChecked /> Auto renew (managed only)
</label>
<label>
Certificate PEM
<textarea name="certificate_pem" placeholder="Paste PEM content for imported certificates" rows={5} />
</label>
<label>
Private key PEM
<textarea name="private_key_pem" placeholder="Paste PEM key for imported certificates" rows={5} />
</label>
<div className="actions">
<button type="submit" className="primary">
Create certificate
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.summary {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.summary h2 {
margin: 0 0 0.35rem;
}
.summary p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
.badge {
padding: 0.3rem 0.8rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
background: rgba(0, 198, 255, 0.15);
color: #5be7ff;
}
details summary {
list-style: none;
cursor: pointer;
}
details summary::-webkit-details-marker {
display: none;
}
.form {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
input,
textarea,
select {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.toggle {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
align-self: flex-start;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { createDeadHost, deleteDeadHost, updateDeadHost } from "@/src/lib/models/dead-hosts";
function parseDomains(value: FormDataEntryValue | null): string[] {
if (!value || typeof value !== "string") {
return [];
}
return value
.replace(/\n/g, ",")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export async function createDeadHostAction(formData: FormData) {
const { user } = requireUser();
await createDeadHost(
{
name: String(formData.get("name") ?? "Dead host"),
domains: parseDomains(formData.get("domains")),
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : 503,
response_body: formData.get("response_body") ? String(formData.get("response_body")) : null,
enabled: formData.has("enabled") ? formData.get("enabled") === "on" : true
},
user.id
);
revalidatePath("/dead-hosts");
}
export async function updateDeadHostAction(id: number, formData: FormData) {
const { user } = requireUser();
await updateDeadHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseDomains(formData.get("domains")) : undefined,
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : undefined,
response_body: formData.get("response_body") ? String(formData.get("response_body")) : undefined,
enabled: formData.has("enabled_present") ? formData.get("enabled") === "on" : undefined
},
user.id
);
revalidatePath("/dead-hosts");
}
export async function deleteDeadHostAction(id: number) {
const { user } = requireUser();
await deleteDeadHost(id, user.id);
revalidatePath("/dead-hosts");
}
+201
View File
@@ -0,0 +1,201 @@
import { listDeadHosts } from "@/src/lib/models/dead-hosts";
import { createDeadHostAction, deleteDeadHostAction, updateDeadHostAction } from "./actions";
export default function DeadHostsPage() {
const hosts = listDeadHosts();
return (
<div className="page">
<header>
<h1>Dead Hosts</h1>
<p>Serve friendly status pages for domains without upstreams.</p>
</header>
<section className="grid">
{hosts.map((host) => (
<div className="card" key={host.id}>
<div className="header">
<div>
<h2>{host.name}</h2>
<p>{host.domains.join(", ")}</p>
</div>
<span className={host.enabled ? "status online" : "status offline"}>{host.enabled ? "Enabled" : "Disabled"}</span>
</div>
<details>
<summary>Edit</summary>
<form action={(formData) => updateDeadHostAction(host.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={host.name} />
</label>
<label>
Domains
<textarea name="domains" defaultValue={host.domains.join("\n")} rows={2} />
</label>
<label>
Status code
<input type="number" name="status_code" defaultValue={host.status_code} min={200} max={599} />
</label>
<label>
Response body (optional)
<textarea name="response_body" defaultValue={host.response_body ?? ""} rows={3} />
</label>
<label className="toggle">
<input type="hidden" name="enabled_present" value="1" />
<input type="checkbox" name="enabled" defaultChecked={host.enabled} /> Enabled
</label>
<div className="actions">
<button type="submit" className="primary">
Save
</button>
</div>
</form>
</details>
<form action={() => deleteDeadHostAction(host.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create dead host</h2>
<form action={createDeadHostAction} className="form">
<label>
Name
<input name="name" placeholder="Maintenance page" required />
</label>
<label>
Domains
<textarea name="domains" placeholder="offline.example.com" rows={2} required />
</label>
<label>
Status code
<input type="number" name="status_code" defaultValue={503} min={200} max={599} />
</label>
<label>
Response body
<textarea name="response_body" placeholder="Service unavailable" rows={3} />
</label>
<label className="toggle">
<input type="checkbox" name="enabled" defaultChecked /> Enabled
</label>
<div className="actions">
<button type="submit" className="primary">
Create Dead Host
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header h2 {
margin: 0 0 0.35rem;
}
.header p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
.status {
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status.online {
background: rgba(0, 200, 83, 0.15);
color: #51ff9d;
}
.status.offline {
background: rgba(255, 91, 91, 0.15);
color: #ff6b6b;
}
details summary {
cursor: pointer;
font-weight: 600;
}
.form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.toggle {
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
input,
textarea {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { ReactNode } from "react";
import { requireUser } from "@/src/lib/auth/session";
import { NavLinks } from "./nav-links";
export default function DashboardLayout({ children }: { children: ReactNode }) {
const { user } = requireUser();
return (
<div className="layout">
<aside>
<div className="brand">
<h2>Caddy Proxy Manager</h2>
<span className="user">{user.name ?? user.email}</span>
</div>
<NavLinks />
<form action="/api/auth/logout" method="POST" className="logout">
<button type="submit">Sign out</button>
</form>
</aside>
<main>{children}</main>
<style jsx>{`
.layout {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
aside {
padding: 2rem 1.75rem;
background: linear-gradient(180deg, #101523 0%, #080b14 100%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
gap: 2rem;
}
.brand h2 {
margin: 0;
font-size: 1.1rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.user {
display: block;
margin-top: 0.5rem;
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
}
.logout button {
width: 100%;
padding: 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.75);
border: none;
cursor: pointer;
}
main {
padding: 2.5rem 3rem;
background: radial-gradient(circle at top left, rgba(0, 114, 255, 0.15), transparent 55%);
}
`}</style>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const NAV_LINKS = [
{ href: "/", label: "Overview" },
{ href: "/proxy-hosts", label: "Proxy Hosts" },
{ href: "/redirects", label: "Redirects" },
{ href: "/dead-hosts", label: "Dead Hosts" },
{ href: "/streams", label: "Streams" },
{ href: "/access-lists", label: "Access Lists" },
{ href: "/certificates", label: "Certificates" },
{ href: "/settings", label: "Settings" },
{ href: "/audit-log", label: "Audit Log" }
];
export function NavLinks() {
const pathname = usePathname();
return (
<nav>
{NAV_LINKS.map((link) => {
const isActive = pathname === link.href;
return (
<Link href={link.href} key={link.href} className={`nav-link${isActive ? " active" : ""}`}>
{link.label}
</Link>
);
})}
<style jsx>{`
nav {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.nav-link {
padding: 0.6rem 0.9rem;
border-radius: 10px;
color: rgba(255, 255, 255, 0.75);
transition: background 0.2s ease, color 0.2s ease;
}
.nav-link:hover,
.nav-link.active {
background: rgba(0, 198, 255, 0.15);
color: #fff;
}
`}</style>
</nav>
);
}
+183
View File
@@ -0,0 +1,183 @@
import Link from "next/link";
import db from "@/src/lib/db";
import { requireUser } from "@/src/lib/auth/session";
type StatCard = {
label: string;
icon: string;
count: number;
href: string;
};
function loadStats(): StatCard[] {
const metrics = [
{
label: "Proxy Hosts",
table: "proxy_hosts",
href: "/proxy-hosts",
icon: "⇄"
},
{
label: "Redirects",
table: "redirect_hosts",
href: "/redirects",
icon: "↪"
},
{
label: "Dead Hosts",
table: "dead_hosts",
href: "/dead-hosts",
icon: "☠"
},
{
label: "Streams",
table: "stream_hosts",
href: "/streams",
icon: "≋"
},
{
label: "Certificates",
table: "certificates",
href: "/certificates",
icon: "🔐"
},
{
label: "Access Lists",
table: "access_lists",
href: "/access-lists",
icon: "🔒"
}
] as const;
return metrics.map((metric) => {
const row = db.prepare(`SELECT COUNT(*) as count FROM ${metric.table}`).get() as { count: number };
return {
label: metric.label,
icon: metric.icon,
count: Number(row.count),
href: metric.href
};
});
}
export default function OverviewPage() {
const { user } = requireUser();
const stats = loadStats();
const recentEvents = db
.prepare(
`SELECT action, entity_type, summary, created_at
FROM audit_events
ORDER BY created_at DESC
LIMIT 8`
)
.all() as { action: string; entity_type: string; summary: string | null; created_at: string }[];
return (
<div className="overview">
<header>
<h1>Welcome back, {user.name ?? user.email}</h1>
<p>Manage your Caddy reverse proxies, TLS certificates, and services with confidence.</p>
</header>
<section className="stats">
{stats.map((stat) => (
<Link className="card" href={stat.href} key={stat.label}>
<span className="icon">{stat.icon}</span>
<span className="value">{stat.count}</span>
<span className="label">{stat.label}</span>
</Link>
))}
</section>
<section className="events">
<h2>Recent Activity</h2>
{recentEvents.length === 0 ? (
<p className="empty">No activity recorded yet.</p>
) : (
<ul>
{recentEvents.map((event, index) => (
<li key={index}>
<span className="summary">{event.summary ?? `${event.action} on ${event.entity_type}`}</span>
<span className="time">{new Date(event.created_at).toLocaleString()}</span>
</li>
))}
</ul>
)}
</section>
<style jsx>{`
.overview {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header h1 {
margin: 0;
font-size: 2rem;
}
header p {
margin: 0.75rem 0 0;
color: rgba(255, 255, 255, 0.65);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1.5rem;
border-radius: 16px;
background: rgba(16, 26, 45, 0.9);
border: 1px solid rgba(255, 255, 255, 0.05);
transition: transform 0.2s ease, border-color 0.2s ease;
}
.card:hover {
transform: translateY(-4px);
border-color: rgba(0, 198, 255, 0.45);
}
.icon {
font-size: 1.35rem;
opacity: 0.65;
}
.value {
font-size: 2.1rem;
font-weight: 600;
}
.label {
color: rgba(255, 255, 255, 0.6);
font-size: 0.95rem;
}
.events h2 {
margin: 0 0 1rem;
}
.events ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.events li {
display: flex;
justify-content: space-between;
padding: 1rem 1.25rem;
border-radius: 12px;
background: rgba(16, 26, 45, 0.8);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.summary {
font-weight: 500;
}
.time {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.empty {
color: rgba(255, 255, 255, 0.55);
}
`}</style>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { createProxyHost, deleteProxyHost, updateProxyHost } from "@/src/lib/models/proxy-hosts";
function parseCsv(value: FormDataEntryValue | null): string[] {
if (!value || typeof value !== "string") {
return [];
}
return value
.replace(/\n/g, ",")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseCheckbox(value: FormDataEntryValue | null): boolean {
return value === "on" || value === "true" || value === "1";
}
export async function createProxyHostAction(formData: FormData) {
const { user } = requireUser();
await createProxyHost(
{
name: String(formData.get("name") ?? "Untitled"),
domains: parseCsv(formData.get("domains")),
upstreams: parseCsv(formData.get("upstreams")),
certificate_id: formData.get("certificate_id") ? Number(formData.get("certificate_id")) : null,
access_list_id: formData.get("access_list_id") ? Number(formData.get("access_list_id")) : null,
ssl_forced: parseCheckbox(formData.get("ssl_forced")),
hsts_enabled: parseCheckbox(formData.get("hsts_enabled")),
hsts_subdomains: parseCheckbox(formData.get("hsts_subdomains")),
allow_websocket: parseCheckbox(formData.get("allow_websocket")),
preserve_host_header: parseCheckbox(formData.get("preserve_host_header")),
enabled: parseCheckbox(formData.get("enabled"))
},
user.id
);
revalidatePath("/proxy-hosts");
}
export async function updateProxyHostAction(id: number, formData: FormData) {
const { user } = requireUser();
const boolField = (key: string) => (formData.has(`${key}_present`) ? parseCheckbox(formData.get(key)) : undefined);
await updateProxyHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseCsv(formData.get("domains")) : undefined,
upstreams: formData.get("upstreams") ? parseCsv(formData.get("upstreams")) : undefined,
certificate_id: formData.get("certificate_id") ? Number(formData.get("certificate_id")) : undefined,
access_list_id: formData.get("access_list_id") ? Number(formData.get("access_list_id")) : undefined,
ssl_forced: boolField("ssl_forced"),
hsts_enabled: boolField("hsts_enabled"),
hsts_subdomains: boolField("hsts_subdomains"),
allow_websocket: boolField("allow_websocket"),
preserve_host_header: boolField("preserve_host_header"),
enabled: boolField("enabled")
},
user.id
);
revalidatePath("/proxy-hosts");
}
export async function deleteProxyHostAction(id: number) {
const { user } = requireUser();
await deleteProxyHost(id, user.id);
revalidatePath("/proxy-hosts");
}
+290
View File
@@ -0,0 +1,290 @@
import { createProxyHostAction, deleteProxyHostAction, updateProxyHostAction } from "./actions";
import { listProxyHosts } from "@/src/lib/models/proxy-hosts";
import { listCertificates } from "@/src/lib/models/certificates";
import { listAccessLists } from "@/src/lib/models/access-lists";
export default function ProxyHostsPage() {
const hosts = listProxyHosts();
const certificates = listCertificates();
const accessLists = listAccessLists();
return (
<div className="page">
<header>
<h1>Proxy Hosts</h1>
<p>Define HTTP(S) reverse proxies managed by Caddy with built-in TLS orchestration.</p>
</header>
<section className="grid">
{hosts.map((host) => (
<div className="host-card" key={host.id}>
<div className="host-header">
<div>
<h2>{host.name}</h2>
<p>{host.domains.join(", ")}</p>
</div>
<span className={host.enabled ? "status online" : "status offline"}>{host.enabled ? "Enabled" : "Disabled"}</span>
</div>
<details>
<summary>Edit configuration</summary>
<form action={(formData) => updateProxyHostAction(host.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={host.name} required />
</label>
<label>
Domains (comma or newline separated)
<textarea name="domains" defaultValue={host.domains.join("\n")} required rows={3} />
</label>
<label>
Upstreams (e.g. 127.0.0.1:3000)
<textarea name="upstreams" defaultValue={host.upstreams.join("\n")} required rows={3} />
</label>
<label>
Certificate
<select name="certificate_id" defaultValue={host.certificate_id ?? ""}>
<option value="">Managed by Caddy</option>
{certificates.map((cert) => (
<option value={cert.id} key={cert.id}>
{cert.name}
</option>
))}
</select>
</label>
<label>
Access List
<select name="access_list_id" defaultValue={host.access_list_id ?? ""}>
<option value="">None</option>
{accessLists.map((list) => (
<option value={list.id} key={list.id}>
{list.name}
</option>
))}
</select>
</label>
<div className="toggles">
<label>
<input type="hidden" name="ssl_forced_present" value="1" />
<input type="checkbox" name="ssl_forced" defaultChecked={host.ssl_forced} /> Force HTTPS
</label>
<label>
<input type="hidden" name="hsts_enabled_present" value="1" />
<input type="checkbox" name="hsts_enabled" defaultChecked={host.hsts_enabled} /> HSTS
</label>
<label>
<input type="hidden" name="hsts_subdomains_present" value="1" />
<input type="checkbox" name="hsts_subdomains" defaultChecked={host.hsts_subdomains} /> Include subdomains in HSTS
</label>
<label>
<input type="hidden" name="allow_websocket_present" value="1" />
<input type="checkbox" name="allow_websocket" defaultChecked={host.allow_websocket} /> Allow WebSocket
</label>
<label>
<input type="hidden" name="preserve_host_header_present" value="1" />
<input type="checkbox" name="preserve_host_header" defaultChecked={host.preserve_host_header} /> Preserve host header
</label>
<label>
<input type="hidden" name="enabled_present" value="1" />
<input type="checkbox" name="enabled" defaultChecked={host.enabled} /> Enabled
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Save Changes
</button>
</div>
</form>
</details>
<form action={() => deleteProxyHostAction(host.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section>
<h2>Create proxy host</h2>
<form action={createProxyHostAction} className="form create">
<label>
Name
<input name="name" placeholder="Internal service" required />
</label>
<label>
Domains
<textarea name="domains" placeholder="app.example.com" rows={2} required />
</label>
<label>
Upstreams
<textarea name="upstreams" placeholder="http://10.0.0.5:8080" rows={2} required />
</label>
<label>
Certificate
<select name="certificate_id" defaultValue="">
<option value="">Managed by Caddy</option>
{certificates.map((cert) => (
<option value={cert.id} key={cert.id}>
{cert.name}
</option>
))}
</select>
</label>
<label>
Access List
<select name="access_list_id" defaultValue="">
<option value="">None</option>
{accessLists.map((list) => (
<option value={list.id} key={list.id}>
{list.name}
</option>
))}
</select>
</label>
<div className="toggles">
<label>
<input type="checkbox" name="ssl_forced" defaultChecked /> Force HTTPS
</label>
<label>
<input type="checkbox" name="hsts_enabled" defaultChecked /> HSTS
</label>
<label>
<input type="checkbox" name="allow_websocket" defaultChecked /> Allow WebSocket
</label>
<label>
<input type="checkbox" name="preserve_host_header" defaultChecked /> Preserve host header
</label>
<label>
<input type="checkbox" name="enabled" defaultChecked /> Enabled
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Create Host
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header h1 {
margin: 0;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.75rem;
}
.host-card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.host-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.host-header h2 {
margin: 0 0 0.4rem;
}
.host-header p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
details summary {
cursor: pointer;
font-weight: 600;
}
.form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
}
input,
textarea,
select {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.toggles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.6rem;
}
.toggles label {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.primary {
padding: 0.65rem 1.5rem;
border-radius: 999px;
border: none;
cursor: pointer;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
font-weight: 600;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.status {
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status.online {
background: rgba(0, 200, 83, 0.15);
color: #51ff9d;
}
.status.offline {
background: rgba(255, 91, 91, 0.15);
color: #ff6b6b;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { createRedirectHost, deleteRedirectHost, updateRedirectHost } from "@/src/lib/models/redirect-hosts";
function parseList(value: FormDataEntryValue | null): string[] {
if (!value || typeof value !== "string") {
return [];
}
return value
.replace(/\n/g, ",")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export async function createRedirectAction(formData: FormData) {
const { user } = requireUser();
await createRedirectHost(
{
name: String(formData.get("name") ?? "Redirect"),
domains: parseList(formData.get("domains")),
destination: String(formData.get("destination") ?? ""),
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : 302,
preserve_query: formData.get("preserve_query") === "on",
enabled: formData.has("enabled") ? formData.get("enabled") === "on" : true
},
user.id
);
revalidatePath("/redirects");
}
export async function updateRedirectAction(id: number, formData: FormData) {
const { user } = requireUser();
await updateRedirectHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseList(formData.get("domains")) : undefined,
destination: formData.get("destination") ? String(formData.get("destination")) : undefined,
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : undefined,
preserve_query: formData.has("preserve_query_present") ? formData.get("preserve_query") === "on" : undefined,
enabled: formData.has("enabled_present") ? formData.get("enabled") === "on" : undefined
},
user.id
);
revalidatePath("/redirects");
}
export async function deleteRedirectAction(id: number) {
const { user } = requireUser();
await deleteRedirectHost(id, user.id);
revalidatePath("/redirects");
}
+217
View File
@@ -0,0 +1,217 @@
import { listRedirectHosts } from "@/src/lib/models/redirect-hosts";
import { createRedirectAction, deleteRedirectAction, updateRedirectAction } from "./actions";
export default function RedirectsPage() {
const redirects = listRedirectHosts();
return (
<div className="page">
<header>
<h1>Redirects</h1>
<p>Return HTTP 301/302 responses to guide clients toward canonical hosts.</p>
</header>
<section className="grid">
{redirects.map((redirect) => (
<div className="card" key={redirect.id}>
<div className="header">
<div>
<h2>{redirect.name}</h2>
<p>{redirect.domains.join(", ")}</p>
</div>
<span className={redirect.enabled ? "status online" : "status offline"}>{redirect.enabled ? "Enabled" : "Disabled"}</span>
</div>
<details>
<summary>Edit</summary>
<form action={(formData) => updateRedirectAction(redirect.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={redirect.name} />
</label>
<label>
Domains
<textarea name="domains" defaultValue={redirect.domains.join("\n")} rows={2} />
</label>
<label>
Destination URL
<input name="destination" defaultValue={redirect.destination} />
</label>
<label>
Status code
<input type="number" name="status_code" defaultValue={redirect.status_code} min={200} max={399} />
</label>
<div className="toggles">
<label>
<input type="hidden" name="preserve_query_present" value="1" />
<input type="checkbox" name="preserve_query" defaultChecked={redirect.preserve_query} /> Preserve path/query
</label>
<label>
<input type="hidden" name="enabled_present" value="1" />
<input type="checkbox" name="enabled" defaultChecked={redirect.enabled} /> Enabled
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Save
</button>
</div>
</form>
</details>
<form action={() => deleteRedirectAction(redirect.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create redirect</h2>
<form action={createRedirectAction} className="form">
<label>
Name
<input name="name" placeholder="Example redirect" required />
</label>
<label>
Domains
<textarea name="domains" placeholder="old.example.com" rows={2} required />
</label>
<label>
Destination URL
<input name="destination" placeholder="https://new.example.com" required />
</label>
<label>
Status code
<input type="number" name="status_code" defaultValue={302} min={200} max={399} />
</label>
<div className="toggles">
<label>
<input type="checkbox" name="preserve_query" defaultChecked /> Preserve path/query
</label>
<label>
<input type="checkbox" name="enabled" defaultChecked /> Enabled
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Create Redirect
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header h2 {
margin: 0 0 0.35rem;
}
.header p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
details summary {
cursor: pointer;
font-weight: 600;
}
.form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
input,
textarea {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.toggles {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.toggles label {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.status {
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status.online {
background: rgba(0, 200, 83, 0.15);
color: #51ff9d;
}
.status.offline {
background: rgba(255, 91, 91, 0.15);
color: #ff6b6b;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { applyCaddyConfig } from "@/src/lib/caddy";
import { saveCloudflareSettings, saveGeneralSettings, saveOAuthSettings } from "@/src/lib/settings";
export async function updateGeneralSettingsAction(formData: FormData) {
requireUser(); // ensure authenticated
saveGeneralSettings({
primaryDomain: String(formData.get("primaryDomain") ?? ""),
acmeEmail: formData.get("acmeEmail") ? String(formData.get("acmeEmail")) : undefined
});
revalidatePath("/settings");
}
export async function updateOAuthSettingsAction(formData: FormData) {
requireUser();
saveOAuthSettings({
authorizationUrl: String(formData.get("authorizationUrl") ?? ""),
tokenUrl: String(formData.get("tokenUrl") ?? ""),
clientId: String(formData.get("clientId") ?? ""),
clientSecret: String(formData.get("clientSecret") ?? ""),
userInfoUrl: String(formData.get("userInfoUrl") ?? ""),
scopes: String(formData.get("scopes") ?? ""),
emailClaim: formData.get("emailClaim") ? String(formData.get("emailClaim")) : undefined,
nameClaim: formData.get("nameClaim") ? String(formData.get("nameClaim")) : undefined,
avatarClaim: formData.get("avatarClaim") ? String(formData.get("avatarClaim")) : undefined
});
revalidatePath("/settings");
}
export async function updateCloudflareSettingsAction(formData: FormData) {
requireUser();
const apiToken = String(formData.get("apiToken") ?? "");
if (!apiToken) {
saveCloudflareSettings({ apiToken: "", zoneId: undefined, accountId: undefined });
} else {
saveCloudflareSettings({
apiToken,
zoneId: formData.get("zoneId") ? String(formData.get("zoneId")) : undefined,
accountId: formData.get("accountId") ? String(formData.get("accountId")) : undefined
});
}
await applyCaddyConfig();
revalidatePath("/settings");
}
+180
View File
@@ -0,0 +1,180 @@
import { getCloudflareSettings, getGeneralSettings, getOAuthSettings } from "@/src/lib/settings";
import { updateCloudflareSettingsAction, updateGeneralSettingsAction, updateOAuthSettingsAction } from "./actions";
export default function SettingsPage() {
const general = getGeneralSettings();
const oauth = getOAuthSettings();
const cloudflare = getCloudflareSettings();
return (
<div className="page">
<header>
<h1>Settings</h1>
<p>Configure organization-wide defaults, authentication, and DNS automation.</p>
</header>
<section className="panel">
<h2>General</h2>
<form action={updateGeneralSettingsAction} className="form">
<label>
Primary domain
<input name="primaryDomain" defaultValue={general?.primaryDomain ?? "caddyproxymanager.com"} required />
</label>
<label>
ACME contact email
<input type="email" name="acmeEmail" defaultValue={general?.acmeEmail ?? ""} placeholder="admin@example.com" />
</label>
<div className="actions">
<button type="submit" className="primary">
Save general settings
</button>
</div>
</form>
</section>
<section className="panel">
<h2>OAuth2 Authentication</h2>
<p className="help">
Provide the OAuth 2.0 endpoints and client credentials issued by your identity provider. Scopes should include profile and email
data.
</p>
<form action={updateOAuthSettingsAction} className="form">
<label>
Authorization URL
<input name="authorizationUrl" defaultValue={oauth?.authorizationUrl ?? ""} required />
</label>
<label>
Token URL
<input name="tokenUrl" defaultValue={oauth?.tokenUrl ?? ""} required />
</label>
<label>
User info URL
<input name="userInfoUrl" defaultValue={oauth?.userInfoUrl ?? ""} required />
</label>
<label>
Client ID
<input name="clientId" defaultValue={oauth?.clientId ?? ""} required />
</label>
<label>
Client secret
<input name="clientSecret" defaultValue={oauth?.clientSecret ?? ""} required />
</label>
<label>
Scopes
<input name="scopes" defaultValue={oauth?.scopes ?? "openid email profile"} />
</label>
<div className="stack">
<label>
Email claim
<input name="emailClaim" defaultValue={oauth?.emailClaim ?? "email"} />
</label>
<label>
Name claim
<input name="nameClaim" defaultValue={oauth?.nameClaim ?? "name"} />
</label>
<label>
Avatar claim
<input name="avatarClaim" defaultValue={oauth?.avatarClaim ?? "picture"} />
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Save OAuth settings
</button>
</div>
</form>
</section>
<section className="panel">
<h2>Cloudflare DNS</h2>
<p className="help">
Configure a Cloudflare API token with <code>Zone.DNS Edit</code> permissions to enable DNS-01 challenges for wildcard certificates.
</p>
<form action={updateCloudflareSettingsAction} className="form">
<label>
API token
<input name="apiToken" defaultValue={cloudflare?.apiToken ?? ""} placeholder="CF_API_TOKEN" />
</label>
<label>
Zone ID
<input name="zoneId" defaultValue={cloudflare?.zoneId ?? ""} />
</label>
<label>
Account ID
<input name="accountId" defaultValue={cloudflare?.accountId ?? ""} />
</label>
<div className="actions">
<button type="submit" className="primary">
Save Cloudflare settings
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.panel {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
input {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.stack {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.help {
margin: 0;
color: rgba(255, 255, 255, 0.55);
font-size: 0.9rem;
}
code {
background: rgba(255, 255, 255, 0.1);
padding: 0.1rem 0.35rem;
border-radius: 6px;
font-size: 0.85rem;
}
`}</style>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth/session";
import { createStreamHost, deleteStreamHost, updateStreamHost } from "@/src/lib/models/stream-hosts";
export async function createStreamAction(formData: FormData) {
const { user } = requireUser();
await createStreamHost(
{
name: String(formData.get("name") ?? "Stream"),
listen_port: Number(formData.get("listen_port")),
protocol: String(formData.get("protocol") ?? "tcp"),
upstream: String(formData.get("upstream") ?? ""),
enabled: formData.has("enabled") ? formData.get("enabled") === "on" : true
},
user.id
);
revalidatePath("/streams");
}
export async function updateStreamAction(id: number, formData: FormData) {
const { user } = requireUser();
await updateStreamHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
listen_port: formData.get("listen_port") ? Number(formData.get("listen_port")) : undefined,
protocol: formData.get("protocol") ? String(formData.get("protocol")) : undefined,
upstream: formData.get("upstream") ? String(formData.get("upstream")) : undefined,
enabled: formData.has("enabled_present") ? formData.get("enabled") === "on" : undefined
},
user.id
);
revalidatePath("/streams");
}
export async function deleteStreamAction(id: number) {
const { user } = requireUser();
await deleteStreamHost(id, user.id);
revalidatePath("/streams");
}
+209
View File
@@ -0,0 +1,209 @@
import { listStreamHosts } from "@/src/lib/models/stream-hosts";
import { createStreamAction, deleteStreamAction, updateStreamAction } from "./actions";
export default function StreamsPage() {
const streams = listStreamHosts();
return (
<div className="page">
<header>
<h1>Streams</h1>
<p>Forward raw TCP/UDP connections through Caddy&apos;s layer4 module.</p>
</header>
<section className="grid">
{streams.map((stream) => (
<div className="card" key={stream.id}>
<div className="header">
<div>
<h2>{stream.name}</h2>
<p>
Listens on :{stream.listen_port} ({stream.protocol.toUpperCase()}) {stream.upstream}
</p>
</div>
<span className={stream.enabled ? "status online" : "status offline"}>{stream.enabled ? "Enabled" : "Disabled"}</span>
</div>
<details>
<summary>Edit</summary>
<form action={(formData) => updateStreamAction(stream.id, formData)} className="form">
<label>
Name
<input name="name" defaultValue={stream.name} />
</label>
<label>
Listen port
<input type="number" name="listen_port" defaultValue={stream.listen_port} min={1} max={65535} />
</label>
<label>
Protocol
<select name="protocol" defaultValue={stream.protocol}>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</label>
<label>
Upstream
<input name="upstream" defaultValue={stream.upstream} />
</label>
<label className="toggle">
<input type="hidden" name="enabled_present" value="1" />
<input type="checkbox" name="enabled" defaultChecked={stream.enabled} /> Enabled
</label>
<div className="actions">
<button type="submit" className="primary">
Save
</button>
</div>
</form>
</details>
<form action={() => deleteStreamAction(stream.id)}>
<button type="submit" className="danger">
Delete
</button>
</form>
</div>
))}
</section>
<section className="create">
<h2>Create stream</h2>
<form action={createStreamAction} className="form">
<label>
Name
<input name="name" placeholder="SSH tunnel" required />
</label>
<label>
Listen port
<input type="number" name="listen_port" placeholder="2222" min={1} max={65535} required />
</label>
<label>
Protocol
<select name="protocol" defaultValue="tcp">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</label>
<label>
Upstream
<input name="upstream" placeholder="10.0.0.12:22" required />
</label>
<label className="toggle">
<input type="checkbox" name="enabled" defaultChecked /> Enabled
</label>
<div className="actions">
<button type="submit" className="primary">
Create Stream
</button>
</div>
</form>
</section>
<style jsx>{`
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
header p {
color: rgba(255, 255, 255, 0.6);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.75rem;
}
.card {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.header h2 {
margin: 0 0 0.35rem;
}
.header p {
margin: 0;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
.status {
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status.online {
background: rgba(0, 200, 83, 0.15);
color: #51ff9d;
}
.status.offline {
background: rgba(255, 91, 91, 0.15);
color: #ff6b6b;
}
details summary {
cursor: pointer;
font-weight: 600;
}
.form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.toggle {
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
input,
select {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 12, 20, 0.9);
color: #fff;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
}
.danger {
background: transparent;
border: 1px solid rgba(255, 91, 91, 0.6);
color: #ff5b5b;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.create {
background: rgba(16, 24, 38, 0.95);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
`}</style>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { finalizeOAuthLogin } from "@/src/lib/auth/oauth";
import { createSession } from "@/src/lib/auth/session";
import { config } from "@/src/lib/config";
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state) {
return NextResponse.redirect(new URL("/login?error=invalid_response", config.baseUrl));
}
try {
const { user, redirectTo } = await finalizeOAuthLogin(code, state);
createSession(user.id);
const destination = redirectTo && redirectTo.startsWith("/") ? redirectTo : "/";
return NextResponse.redirect(new URL(destination, config.baseUrl));
} catch (error) {
console.error("OAuth callback failed", error);
return NextResponse.redirect(new URL("/login?error=oauth_failed", config.baseUrl));
}
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { destroySession } from "@/src/lib/auth/session";
import { config } from "@/src/lib/config";
export async function POST() {
destroySession();
return NextResponse.redirect(new URL("/login", config.baseUrl));
}
+31
View File
@@ -0,0 +1,31 @@
:root {
color-scheme: light dark;
}
* {
box-sizing: border-box;
}
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
background: #0a0d13;
color: #f9fafc;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
input,
select,
textarea {
font: inherit;
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import "./globals.css";
import { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use server";
import { redirect } from "next/navigation";
import { getUserCount } from "@/src/lib/models/user";
import { saveOAuthSettings } from "@/src/lib/settings";
export async function initialOAuthSetupAction(formData: FormData) {
if (getUserCount() > 0) {
redirect("/login");
}
saveOAuthSettings({
authorizationUrl: String(formData.get("authorizationUrl") ?? ""),
tokenUrl: String(formData.get("tokenUrl") ?? ""),
userInfoUrl: String(formData.get("userInfoUrl") ?? ""),
clientId: String(formData.get("clientId") ?? ""),
clientSecret: String(formData.get("clientSecret") ?? ""),
scopes: String(formData.get("scopes") ?? ""),
emailClaim: formData.get("emailClaim") ? String(formData.get("emailClaim")) : undefined,
nameClaim: formData.get("nameClaim") ? String(formData.get("nameClaim")) : undefined,
avatarClaim: formData.get("avatarClaim") ? String(formData.get("avatarClaim")) : undefined
});
redirect("/login");
}
+128
View File
@@ -0,0 +1,128 @@
import { redirect } from "next/navigation";
import { getOAuthSettings } from "@/src/lib/settings";
import { getUserCount } from "@/src/lib/models/user";
import { initialOAuthSetupAction } from "./actions";
export default function OAuthSetupPage() {
if (getUserCount() > 0 && getOAuthSettings()) {
redirect("/login");
}
return (
<div className="page">
<div className="panel">
<h1>Configure OAuth2</h1>
<p>
Provide the OAuth configuration for your identity provider to finish setting up Caddy Proxy Manager. The first user who signs in
becomes the administrator.
</p>
<form action={initialOAuthSetupAction} className="form">
<label>
Authorization URL
<input name="authorizationUrl" placeholder="https://id.example.com/oauth2/authorize" required />
</label>
<label>
Token URL
<input name="tokenUrl" placeholder="https://id.example.com/oauth2/token" required />
</label>
<label>
User info URL
<input name="userInfoUrl" placeholder="https://id.example.com/oauth2/userinfo" required />
</label>
<label>
Client ID
<input name="clientId" placeholder="client-id" required />
</label>
<label>
Client secret
<input name="clientSecret" placeholder="client-secret" required />
</label>
<label>
Scopes
<input name="scopes" defaultValue="openid email profile" />
</label>
<div className="stack">
<label>
Email claim
<input name="emailClaim" defaultValue="email" />
</label>
<label>
Name claim
<input name="nameClaim" defaultValue="name" />
</label>
<label>
Avatar claim
<input name="avatarClaim" defaultValue="picture" />
</label>
</div>
<div className="actions">
<button type="submit" className="primary">
Save OAuth configuration
</button>
</div>
</form>
</div>
<style jsx>{`
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, rgba(0, 114, 255, 0.2), rgba(3, 8, 18, 0.95));
}
.panel {
width: min(640px, 90vw);
background: rgba(8, 12, 20, 0.95);
padding: 2.5rem;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h1 {
margin: 0;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.7);
}
.form {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
input {
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(3, 8, 18, 0.92);
color: #fff;
}
.stack {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.actions {
display: flex;
justify-content: flex-end;
}
.primary {
padding: 0.75rem 1.6rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #00c6ff 0%, #0072ff 100%);
color: #fff;
cursor: pointer;
font-weight: 600;
}
`}</style>
</div>
);
}
-73
View File
@@ -1,73 +0,0 @@
{
"env": {
"node": true,
"es6": true
},
"extends": [
"eslint:recommended"
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": [
"align-assignments"
],
"rules": {
"arrow-parens": [
"error",
"always"
],
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"key-spacing": [
"error",
{
"align": "value"
}
],
"comma-spacing": [
"error",
{
"before": false,
"after": true
}
],
"func-call-spacing": [
"error",
"never"
],
"keyword-spacing": [
"error",
{
"before": true
}
],
"no-irregular-whitespace": "error",
"no-unused-expressions": 0,
"align-assignments/align-assignments": [
2,
{
"requiresOnly": false
}
]
}
}
-8
View File
@@ -1,8 +0,0 @@
config/development.json
data/*
yarn-error.log
tmp
certbot.log
node_modules
core.*
-11
View File
@@ -1,11 +0,0 @@
{
"printWidth": 320,
"tabWidth": 4,
"useTabs": true,
"semi": true,
"singleQuote": true,
"bracketSpacing": true,
"jsxBracketSameLine": true,
"trailingComma": "all",
"proseWrap": "always"
}
-90
View File
@@ -1,90 +0,0 @@
const express = require('express');
const bodyParser = require('body-parser');
const fileUpload = require('express-fileupload');
const compression = require('compression');
const config = require('./lib/config');
const log = require('./logger').express;
/**
* App
*/
const app = express();
app.use(fileUpload());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: true}));
// Gzip
app.use(compression());
/**
* General Logging, BEFORE routes
*/
app.disable('x-powered-by');
app.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
app.enable('strict routing');
// pretty print JSON when not live
if (config.debug()) {
app.set('json spaces', 2);
}
// CORS for everything
app.use(require('./lib/express/cors'));
// General security/cache related headers + server header
app.use(function (req, res, next) {
let x_frame_options = 'DENY';
if (typeof process.env.X_FRAME_OPTIONS !== 'undefined' && process.env.X_FRAME_OPTIONS) {
x_frame_options = process.env.X_FRAME_OPTIONS;
}
res.set({
'X-XSS-Protection': '1; mode=block',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': x_frame_options,
'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate',
Pragma: 'no-cache',
Expires: 0
});
next();
});
app.use(require('./lib/express/jwt')());
app.use('/', require('./routes/main'));
// production error handler
// no stacktraces leaked to user
// eslint-disable-next-line
app.use(function (err, req, res, next) {
let payload = {
error: {
code: err.status,
message: err.public ? err.message : 'Internal Error'
}
};
if (config.debug() || (req.baseUrl + req.path).includes('nginx/certificates')) {
payload.debug = {
stack: typeof err.stack !== 'undefined' && err.stack ? err.stack.split('\n') : null,
previous: err.previous
};
}
// Not every error is worth logging - but this is good for now until it gets annoying.
if (typeof err.stack !== 'undefined' && err.stack) {
if (config.debug()) {
log.debug(err.stack);
} else if (typeof err.public == 'undefined' || !err.public) {
log.warn(err.message);
}
}
res
.status(err.status || 500)
.send(payload);
});
module.exports = app;
-2
View File
@@ -1,2 +0,0 @@
These files are use in development and are not deployed as part of the final product.
-10
View File
@@ -1,10 +0,0 @@
{
"database": {
"engine": "mysql2",
"host": "db",
"name": "npm",
"user": "npm",
"password": "npm",
"port": 3306
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"database": {
"engine": "knex-native",
"knex": {
"client": "sqlite3",
"connection": {
"filename": "/app/config/mydb.sqlite"
},
"pool": {
"min": 0,
"max": 1,
"createTimeoutMillis": 3000,
"acquireTimeoutMillis": 30000,
"idleTimeoutMillis": 30000,
"reapIntervalMillis": 1000,
"createRetryIntervalMillis": 100,
"propagateCreateError": false
},
"migrations": {
"tableName": "migrations",
"stub": "src/backend/lib/migrate_template.js",
"directory": "src/backend/migrations"
}
}
}
}
-27
View File
@@ -1,27 +0,0 @@
const config = require('./lib/config');
if (!config.has('database')) {
throw new Error('Database config does not exist! Please read the instructions: https://nginxproxymanager.com/setup/');
}
function generateDbConfig() {
const cfg = config.get('database');
if (cfg.engine === 'knex-native') {
return cfg.knex;
}
return {
client: cfg.engine,
connection: {
host: cfg.host,
user: cfg.user,
password: cfg.password,
database: cfg.name,
port: cfg.port
},
migrations: {
tableName: 'migrations'
}
};
}
module.exports = require('knex')(generateDbConfig());
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env node
const schema = require('./schema');
const logger = require('./logger').global;
const IP_RANGES_FETCH_ENABLED = process.env.IP_RANGES_FETCH_ENABLED !== 'false';
async function appStart () {
const migrate = require('./migrate');
const setup = require('./setup');
const app = require('./app');
const internalCertificate = require('./internal/certificate');
const internalIpRanges = require('./internal/ip_ranges');
return migrate.latest()
.then(setup)
.then(schema.getCompiledSchema)
.then(() => {
if (IP_RANGES_FETCH_ENABLED) {
logger.info('IP Ranges fetch is enabled');
return internalIpRanges.fetch().catch((err) => {
logger.error('IP Ranges fetch failed, continuing anyway:', err.message);
});
} else {
logger.info('IP Ranges fetch is disabled by environment variable');
}
})
.then(() => {
internalCertificate.initTimer();
internalIpRanges.initTimer();
const server = app.listen(3000, () => {
logger.info('Backend PID ' + process.pid + ' listening on port 3000 ...');
process.on('SIGTERM', () => {
logger.info('PID ' + process.pid + ' received SIGTERM');
server.close(() => {
logger.info('Stopping.');
process.exit(0);
});
});
});
})
.catch((err) => {
logger.error(err.message, err);
setTimeout(appStart, 1000);
});
}
try {
appStart();
} catch (err) {
logger.error(err.message, err);
process.exit(1);
}
-540
View File
@@ -1,540 +0,0 @@
const _ = require('lodash');
const fs = require('node:fs');
const batchflow = require('batchflow');
const logger = require('../logger').access;
const error = require('../lib/error');
const utils = require('../lib/utils');
const accessListModel = require('../models/access_list');
const accessListAuthModel = require('../models/access_list_auth');
const accessListClientModel = require('../models/access_list_client');
const proxyHostModel = require('../models/proxy_host');
const internalAuditLog = require('./audit-log');
const internalNginx = require('./nginx');
function omissions () {
return ['is_deleted'];
}
const internalAccessList = {
/**
* @param {Access} access
* @param {Object} data
* @returns {Promise}
*/
create: (access, data) => {
return access.can('access_lists:create', data)
.then((/*access_data*/) => {
return accessListModel
.query()
.insertAndFetch({
name: data.name,
satisfy_any: data.satisfy_any,
pass_auth: data.pass_auth,
owner_user_id: access.token.getUserId(1)
})
.then(utils.omitRow(omissions()));
})
.then((row) => {
data.id = row.id;
const promises = [];
// Now add the items
data.items.map((item) => {
promises.push(accessListAuthModel
.query()
.insert({
access_list_id: row.id,
username: item.username,
password: item.password
})
);
});
// Now add the clients
if (typeof data.clients !== 'undefined' && data.clients) {
data.clients.map((client) => {
promises.push(accessListClientModel
.query()
.insert({
access_list_id: row.id,
address: client.address,
directive: client.directive
})
);
});
}
return Promise.all(promises);
})
.then(() => {
// re-fetch with expansions
return internalAccessList.get(access, {
id: data.id,
expand: ['owner', 'items', 'clients', 'proxy_hosts.access_list.[clients,items]']
}, true /* <- skip masking */);
})
.then((row) => {
// Audit log
data.meta = _.assign({}, data.meta || {}, row.meta);
return internalAccessList.build(row)
.then(() => {
if (parseInt(row.proxy_host_count, 10)) {
return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts);
}
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'created',
object_type: 'access-list',
object_id: row.id,
meta: internalAccessList.maskItems(data)
});
})
.then(() => {
return internalAccessList.maskItems(row);
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} [data.name]
* @param {String} [data.items]
* @return {Promise}
*/
update: (access, data) => {
return access.can('access_lists:update', data.id)
.then((/*access_data*/) => {
return internalAccessList.get(access, {id: data.id});
})
.then((row) => {
if (row.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError(`Access List could not be updated, IDs do not match: ${row.id} !== ${data.id}`);
}
})
.then(() => {
// patch name if specified
if (typeof data.name !== 'undefined' && data.name) {
return accessListModel
.query()
.where({id: data.id})
.patch({
name: data.name,
satisfy_any: data.satisfy_any,
pass_auth: data.pass_auth,
});
}
})
.then(() => {
// Check for items and add/update/remove them
if (typeof data.items !== 'undefined' && data.items) {
const promises = [];
const items_to_keep = [];
data.items.map((item) => {
if (item.password) {
promises.push(accessListAuthModel
.query()
.insert({
access_list_id: data.id,
username: item.username,
password: item.password
})
);
} else {
// This was supplied with an empty password, which means keep it but don't change the password
items_to_keep.push(item.username);
}
});
const query = accessListAuthModel
.query()
.delete()
.where('access_list_id', data.id);
if (items_to_keep.length) {
query.andWhere('username', 'NOT IN', items_to_keep);
}
return query
.then(() => {
// Add new items
if (promises.length) {
return Promise.all(promises);
}
});
}
})
.then(() => {
// Check for clients and add/update/remove them
if (typeof data.clients !== 'undefined' && data.clients) {
const promises = [];
data.clients.map((client) => {
if (client.address) {
promises.push(accessListClientModel
.query()
.insert({
access_list_id: data.id,
address: client.address,
directive: client.directive
})
);
}
});
const query = accessListClientModel
.query()
.delete()
.where('access_list_id', data.id);
return query
.then(() => {
// Add new items
if (promises.length) {
return Promise.all(promises);
}
});
}
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'access-list',
object_id: data.id,
meta: internalAccessList.maskItems(data)
});
})
.then(() => {
// re-fetch with expansions
return internalAccessList.get(access, {
id: data.id,
expand: ['owner', 'items', 'clients', 'proxy_hosts.[certificate,access_list.[clients,items]]']
}, true /* <- skip masking */);
})
.then((row) => {
return internalAccessList.build(row)
.then(() => {
if (parseInt(row.proxy_host_count, 10)) {
return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts);
}
}).then(internalNginx.reload)
.then(() => {
return internalAccessList.maskItems(row);
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {Array} [data.expand]
* @param {Array} [data.omit]
* @param {Boolean} [skip_masking]
* @return {Promise}
*/
get: (access, data, skip_masking) => {
if (typeof data === 'undefined') {
data = {};
}
return access.can('access_lists:get', data.id)
.then((access_data) => {
const query = accessListModel
.query()
.select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count'))
.leftJoin('proxy_host', function() {
this.on('proxy_host.access_list_id', '=', 'access_list.id')
.andOn('proxy_host.is_deleted', '=', 0);
})
.where('access_list.is_deleted', 0)
.andWhere('access_list.id', data.id)
.groupBy('access_list.id')
.allowGraph('[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]')
.first();
if (access_data.permission_visibility !== 'all') {
query.andWhere('access_list.owner_user_id', access.token.getUserId(1));
}
if (typeof data.expand !== 'undefined' && data.expand !== null) {
query.withGraphFetched(`[${data.expand.join(', ')}]`);
}
return query.then(utils.omitRow(omissions()));
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
if (!skip_masking && typeof row.items !== 'undefined' && row.items) {
row = internalAccessList.maskItems(row);
}
// Custom omissions
if (typeof data.omit !== 'undefined' && data.omit !== null) {
row = _.omit(row, data.omit);
}
return row;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
delete: (access, data) => {
return access.can('access_lists:delete', data.id)
.then(() => {
return internalAccessList.get(access, {id: data.id, expand: ['proxy_hosts', 'items', 'clients']});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
// 1. update row to be deleted
// 2. update any proxy hosts that were using it (ignoring permissions)
// 3. reconfigure those hosts
// 4. audit log
// 1. update row to be deleted
return accessListModel
.query()
.where('id', row.id)
.patch({
is_deleted: 1
})
.then(() => {
// 2. update any proxy hosts that were using it (ignoring permissions)
if (row.proxy_hosts) {
return proxyHostModel
.query()
.where('access_list_id', '=', row.id)
.patch({access_list_id: 0})
.then(() => {
// 3. reconfigure those hosts, then reload nginx
// set the access_list_id to zero for these items
row.proxy_hosts.map((_val, idx) => {
row.proxy_hosts[idx].access_list_id = 0;
});
return internalNginx.bulkGenerateConfigs('proxy_host', row.proxy_hosts);
})
.then(() => {
return internalNginx.reload();
});
}
})
.then(() => {
// delete the htpasswd file
const htpasswd_file = internalAccessList.getFilename(row);
try {
fs.unlinkSync(htpasswd_file);
} catch (_err) {
// do nothing
}
})
.then(() => {
// 4. audit log
return internalAuditLog.add(access, {
action: 'deleted',
object_type: 'access-list',
object_id: row.id,
meta: _.omit(internalAccessList.maskItems(row), ['is_deleted', 'proxy_hosts'])
});
});
})
.then(() => {
return true;
});
},
/**
* All Lists
*
* @param {Access} access
* @param {Array} [expand]
* @param {String} [search_query]
* @returns {Promise}
*/
getAll: (access, expand, search_query) => {
return access.can('access_lists:list')
.then((access_data) => {
const query = accessListModel
.query()
.select('access_list.*', accessListModel.raw('COUNT(proxy_host.id) as proxy_host_count'))
.leftJoin('proxy_host', function() {
this.on('proxy_host.access_list_id', '=', 'access_list.id')
.andOn('proxy_host.is_deleted', '=', 0);
})
.where('access_list.is_deleted', 0)
.groupBy('access_list.id')
.allowGraph('[owner,items,clients]')
.orderBy('access_list.name', 'ASC');
if (access_data.permission_visibility !== 'all') {
query.andWhere('access_list.owner_user_id', access.token.getUserId(1));
}
// Query is used for searching
if (typeof search_query === 'string') {
query.where(function () {
this.where('name', 'like', `%${search_query}%`);
});
}
if (typeof expand !== 'undefined' && expand !== null) {
query.withGraphFetched(`[${expand.join(', ')}]`);
}
return query.then(utils.omitRows(omissions()));
})
.then((rows) => {
if (rows) {
rows.map((row, idx) => {
if (typeof row.items !== 'undefined' && row.items) {
rows[idx] = internalAccessList.maskItems(row);
}
});
}
return rows;
});
},
/**
* Report use
*
* @param {Integer} user_id
* @param {String} visibility
* @returns {Promise}
*/
getCount: (user_id, visibility) => {
const query = accessListModel
.query()
.count('id as count')
.where('is_deleted', 0);
if (visibility !== 'all') {
query.andWhere('owner_user_id', user_id);
}
return query.first()
.then((row) => {
return parseInt(row.count, 10);
});
},
/**
* @param {Object} list
* @returns {Object}
*/
maskItems: (list) => {
if (list && typeof list.items !== 'undefined') {
list.items.map((val, idx) => {
let repeat_for = 8;
let first_char = '*';
if (typeof val.password !== 'undefined' && val.password) {
repeat_for = val.password.length - 1;
first_char = val.password.charAt(0);
}
list.items[idx].hint = first_char + ('*').repeat(repeat_for);
list.items[idx].password = '';
});
}
return list;
},
/**
* @param {Object} list
* @param {Integer} list.id
* @returns {String}
*/
getFilename: (list) => {
return `/data/access/${list.id}`;
},
/**
* @param {Object} list
* @param {Integer} list.id
* @param {String} list.name
* @param {Array} list.items
* @returns {Promise}
*/
build: (list) => {
logger.info(`Building Access file #${list.id} for: ${list.name}`);
return new Promise((resolve, reject) => {
const htpasswd_file = internalAccessList.getFilename(list);
// 1. remove any existing access file
try {
fs.unlinkSync(htpasswd_file);
} catch (_err) {
// do nothing
}
// 2. create empty access file
try {
fs.writeFileSync(htpasswd_file, '', {encoding: 'utf8'});
resolve(htpasswd_file);
} catch (err) {
reject(err);
}
})
.then((htpasswd_file) => {
// 3. generate password for each user
if (list.items.length) {
return new Promise((resolve, reject) => {
batchflow(list.items).sequential()
.each((_i, item, next) => {
if (typeof item.password !== 'undefined' && item.password.length) {
logger.info(`Adding: ${item.username}`);
utils.execFile('openssl', ['passwd', '-apr1', item.password])
.then((res) => {
try {
fs.appendFileSync(htpasswd_file, `${item.username}:${res}\n`, {encoding: 'utf8'});
} catch (err) {
reject(err);
}
next();
})
.catch((err) => {
logger.error(err);
next(err);
});
}
})
.error((err) => {
logger.error(err);
reject(err);
})
.end((results) => {
logger.success(`Built Access file #${list.id} for: ${list.name}`);
resolve(results);
});
});
}
});
}
};
module.exports = internalAccessList;
-79
View File
@@ -1,79 +0,0 @@
const error = require('../lib/error');
const auditLogModel = require('../models/audit-log');
const {castJsonIfNeed} = require('../lib/helpers');
const internalAuditLog = {
/**
* All logs
*
* @param {Access} access
* @param {Array} [expand]
* @param {String} [search_query]
* @returns {Promise}
*/
getAll: (access, expand, search_query) => {
return access.can('auditlog:list')
.then(() => {
let query = auditLogModel
.query()
.orderBy('created_on', 'DESC')
.orderBy('id', 'DESC')
.limit(100)
.allowGraph('[user]');
// Query is used for searching
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where(castJsonIfNeed('meta'), 'like', '%' + search_query + '%');
});
}
if (typeof expand !== 'undefined' && expand !== null) {
query.withGraphFetched('[' + expand.join(', ') + ']');
}
return query;
});
},
/**
* This method should not be publicly used, it doesn't check certain things. It will be assumed
* that permission to add to audit log is already considered, however the access token is used for
* default user id determination.
*
* @param {Access} access
* @param {Object} data
* @param {String} data.action
* @param {Number} [data.user_id]
* @param {Number} [data.object_id]
* @param {Number} [data.object_type]
* @param {Object} [data.meta]
* @returns {Promise}
*/
add: (access, data) => {
return new Promise((resolve, reject) => {
// Default the user id
if (typeof data.user_id === 'undefined' || !data.user_id) {
data.user_id = access.token.getUserId(1);
}
if (typeof data.action === 'undefined' || !data.action) {
reject(new error.InternalValidationError('Audit log entry must contain an Action'));
} else {
// Make sure at least 1 of the IDs are set and action
resolve(auditLogModel
.query()
.insert({
user_id: data.user_id,
action: data.action,
object_type: data.object_type || '',
object_id: data.object_id || 0,
meta: data.meta || {}
}));
}
});
}
};
module.exports = internalAuditLog;
File diff suppressed because it is too large Load Diff
-465
View File
@@ -1,465 +0,0 @@
const _ = require('lodash');
const error = require('../lib/error');
const utils = require('../lib/utils');
const deadHostModel = require('../models/dead_host');
const internalHost = require('./host');
const internalNginx = require('./nginx');
const internalAuditLog = require('./audit-log');
const internalCertificate = require('./certificate');
const {castJsonIfNeed} = require('../lib/helpers');
function omissions () {
return ['is_deleted'];
}
const internalDeadHost = {
/**
* @param {Access} access
* @param {Object} data
* @returns {Promise}
*/
create: (access, data) => {
let create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('dead_hosts:create', data)
.then((/*access_data*/) => {
// Get a list of the domain names and check each of them against existing records
let domain_name_check_promises = [];
data.domain_names.map(function (domain_name) {
domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name));
});
return Promise.all(domain_name_check_promises)
.then((check_results) => {
check_results.map(function (result) {
if (result.is_taken) {
throw new error.ValidationError(result.hostname + ' is already in use');
}
});
});
})
.then(() => {
// At this point the domains should have been checked
data.owner_user_id = access.token.getUserId(1);
data = internalHost.cleanSslHstsData(data);
// Fix for db field not having a default value
// for this optional field.
if (typeof data.advanced_config === 'undefined') {
data.advanced_config = '';
}
return deadHostModel
.query()
.insertAndFetch(data)
.then(utils.omitRow(omissions()));
})
.then((row) => {
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, data)
.then((cert) => {
// update host with cert id
return internalDeadHost.update(access, {
id: row.id,
certificate_id: cert.id
});
})
.then(() => {
return row;
});
} else {
return row;
}
})
.then((row) => {
// re-fetch with cert
return internalDeadHost.get(access, {
id: row.id,
expand: ['certificate', 'owner']
});
})
.then((row) => {
// Configure nginx
return internalNginx.configure(deadHostModel, 'dead_host', row)
.then(() => {
return row;
});
})
.then((row) => {
data.meta = _.assign({}, data.meta || {}, row.meta);
// Add to audit log
return internalAuditLog.add(access, {
action: 'created',
object_type: 'dead-host',
object_id: row.id,
meta: data
})
.then(() => {
return row;
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @return {Promise}
*/
update: (access, data) => {
let create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('dead_hosts:update', data.id)
.then((/*access_data*/) => {
// Get a list of the domain names and check each of them against existing records
let domain_name_check_promises = [];
if (typeof data.domain_names !== 'undefined') {
data.domain_names.map(function (domain_name) {
domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name, 'dead', data.id));
});
return Promise.all(domain_name_check_promises)
.then((check_results) => {
check_results.map(function (result) {
if (result.is_taken) {
throw new error.ValidationError(result.hostname + ' is already in use');
}
});
});
}
})
.then(() => {
return internalDeadHost.get(access, {id: data.id});
})
.then((row) => {
if (row.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('404 Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
}
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, {
domain_names: data.domain_names || row.domain_names,
meta: _.assign({}, row.meta, data.meta)
})
.then((cert) => {
// update host with cert id
data.certificate_id = cert.id;
})
.then(() => {
return row;
});
} else {
return row;
}
})
.then((row) => {
// Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here.
data = _.assign({}, {
domain_names: row.domain_names
}, data);
data = internalHost.cleanSslHstsData(data, row);
return deadHostModel
.query()
.where({id: data.id})
.patch(data)
.then((saved_row) => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'dead-host',
object_id: row.id,
meta: data
})
.then(() => {
return _.omit(saved_row, omissions());
});
});
})
.then(() => {
return internalDeadHost.get(access, {
id: data.id,
expand: ['owner', 'certificate']
})
.then((row) => {
// Configure nginx
return internalNginx.configure(deadHostModel, 'dead_host', row)
.then((new_meta) => {
row.meta = new_meta;
row = internalHost.cleanRowCertificateMeta(row);
return _.omit(row, omissions());
});
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {Array} [data.expand]
* @param {Array} [data.omit]
* @return {Promise}
*/
get: (access, data) => {
if (typeof data === 'undefined') {
data = {};
}
return access.can('dead_hosts:get', data.id)
.then((access_data) => {
let query = deadHostModel
.query()
.where('is_deleted', 0)
.andWhere('id', data.id)
.allowGraph('[owner,certificate]')
.first();
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
if (typeof data.expand !== 'undefined' && data.expand !== null) {
query.withGraphFetched('[' + data.expand.join(', ') + ']');
}
return query.then(utils.omitRow(omissions()));
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
// Custom omissions
if (typeof data.omit !== 'undefined' && data.omit !== null) {
row = _.omit(row, data.omit);
}
return row;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
delete: (access, data) => {
return access.can('dead_hosts:delete', data.id)
.then(() => {
return internalDeadHost.get(access, {id: data.id});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
return deadHostModel
.query()
.where('id', row.id)
.patch({
is_deleted: 1
})
.then(() => {
// Delete Nginx Config
return internalNginx.deleteConfig('dead_host', row)
.then(() => {
return internalNginx.reload();
});
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'deleted',
object_type: 'dead-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
enable: (access, data) => {
return access.can('dead_hosts:update', data.id)
.then(() => {
return internalDeadHost.get(access, {
id: data.id,
expand: ['certificate', 'owner']
});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
} else if (row.enabled) {
throw new error.ValidationError('Host is already enabled');
}
row.enabled = 1;
return deadHostModel
.query()
.where('id', row.id)
.patch({
enabled: 1
})
.then(() => {
// Configure nginx
return internalNginx.configure(deadHostModel, 'dead_host', row);
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'enabled',
object_type: 'dead-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
disable: (access, data) => {
return access.can('dead_hosts:update', data.id)
.then(() => {
return internalDeadHost.get(access, {id: data.id});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
} else if (!row.enabled) {
throw new error.ValidationError('Host is already disabled');
}
row.enabled = 0;
return deadHostModel
.query()
.where('id', row.id)
.patch({
enabled: 0
})
.then(() => {
// Delete Nginx Config
return internalNginx.deleteConfig('dead_host', row)
.then(() => {
return internalNginx.reload();
});
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'disabled',
object_type: 'dead-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* All Hosts
*
* @param {Access} access
* @param {Array} [expand]
* @param {String} [search_query]
* @returns {Promise}
*/
getAll: (access, expand, search_query) => {
return access.can('dead_hosts:list')
.then((access_data) => {
let query = deadHostModel
.query()
.where('is_deleted', 0)
.groupBy('id')
.allowGraph('[owner,certificate]')
.orderBy(castJsonIfNeed('domain_names'), 'ASC');
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
// Query is used for searching
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where(castJsonIfNeed('domain_names'), 'like', '%' + search_query + '%');
});
}
if (typeof expand !== 'undefined' && expand !== null) {
query.withGraphFetched('[' + expand.join(', ') + ']');
}
return query.then(utils.omitRows(omissions()));
})
.then((rows) => {
if (typeof expand !== 'undefined' && expand !== null && expand.indexOf('certificate') !== -1) {
return internalHost.cleanAllRowsCertificateMeta(rows);
}
return rows;
});
},
/**
* Report use
*
* @param {Number} user_id
* @param {String} visibility
* @returns {Promise}
*/
getCount: (user_id, visibility) => {
let query = deadHostModel
.query()
.count('id as count')
.where('is_deleted', 0);
if (visibility !== 'all') {
query.andWhere('owner_user_id', user_id);
}
return query.first()
.then((row) => {
return parseInt(row.count, 10);
});
}
};
module.exports = internalDeadHost;
-236
View File
@@ -1,236 +0,0 @@
const _ = require('lodash');
const proxyHostModel = require('../models/proxy_host');
const redirectionHostModel = require('../models/redirection_host');
const deadHostModel = require('../models/dead_host');
const {castJsonIfNeed} = require('../lib/helpers');
const internalHost = {
/**
* Makes sure that the ssl_* and hsts_* fields play nicely together.
* ie: if there is no cert, then force_ssl is off.
* if force_ssl is off, then hsts_enabled is definitely off.
*
* @param {object} data
* @param {object} [existing_data]
* @returns {object}
*/
cleanSslHstsData: function (data, existing_data) {
existing_data = existing_data === undefined ? {} : existing_data;
const combined_data = _.assign({}, existing_data, data);
if (!combined_data.certificate_id) {
combined_data.ssl_forced = false;
combined_data.http2_support = false;
}
if (!combined_data.ssl_forced) {
combined_data.hsts_enabled = false;
}
if (!combined_data.hsts_enabled) {
combined_data.hsts_subdomains = false;
}
return combined_data;
},
/**
* used by the getAll functions of hosts, this removes the certificate meta if present
*
* @param {Array} rows
* @returns {Array}
*/
cleanAllRowsCertificateMeta: function (rows) {
rows.map(function (row, idx) {
if (typeof rows[idx].certificate !== 'undefined' && rows[idx].certificate) {
rows[idx].certificate.meta = {};
}
});
return rows;
},
/**
* used by the get/update functions of hosts, this removes the certificate meta if present
*
* @param {Object} row
* @returns {Object}
*/
cleanRowCertificateMeta: function (row) {
if (typeof row.certificate !== 'undefined' && row.certificate) {
row.certificate.meta = {};
}
return row;
},
/**
* This returns all the host types with any domain listed in the provided domain_names array.
* This is used by the certificates to temporarily disable any host that is using the domain
*
* @param {Array} domain_names
* @returns {Promise}
*/
getHostsWithDomains: function (domain_names) {
const promises = [
proxyHostModel
.query()
.where('is_deleted', 0),
redirectionHostModel
.query()
.where('is_deleted', 0),
deadHostModel
.query()
.where('is_deleted', 0)
];
return Promise.all(promises)
.then((promises_results) => {
let response_object = {
total_count: 0,
dead_hosts: [],
proxy_hosts: [],
redirection_hosts: []
};
if (promises_results[0]) {
// Proxy Hosts
response_object.proxy_hosts = internalHost._getHostsWithDomains(promises_results[0], domain_names);
response_object.total_count += response_object.proxy_hosts.length;
}
if (promises_results[1]) {
// Redirection Hosts
response_object.redirection_hosts = internalHost._getHostsWithDomains(promises_results[1], domain_names);
response_object.total_count += response_object.redirection_hosts.length;
}
if (promises_results[2]) {
// Dead Hosts
response_object.dead_hosts = internalHost._getHostsWithDomains(promises_results[2], domain_names);
response_object.total_count += response_object.dead_hosts.length;
}
return response_object;
});
},
/**
* Internal use only, checks to see if the domain is already taken by any other record
*
* @param {String} hostname
* @param {String} [ignore_type] 'proxy', 'redirection', 'dead'
* @param {Integer} [ignore_id] Must be supplied if type was also supplied
* @returns {Promise}
*/
isHostnameTaken: function (hostname, ignore_type, ignore_id) {
const promises = [
proxyHostModel
.query()
.where('is_deleted', 0)
.andWhere(castJsonIfNeed('domain_names'), 'like', '%' + hostname + '%'),
redirectionHostModel
.query()
.where('is_deleted', 0)
.andWhere(castJsonIfNeed('domain_names'), 'like', '%' + hostname + '%'),
deadHostModel
.query()
.where('is_deleted', 0)
.andWhere(castJsonIfNeed('domain_names'), 'like', '%' + hostname + '%')
];
return Promise.all(promises)
.then((promises_results) => {
let is_taken = false;
if (promises_results[0]) {
// Proxy Hosts
if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[0], ignore_type === 'proxy' && ignore_id ? ignore_id : 0)) {
is_taken = true;
}
}
if (promises_results[1]) {
// Redirection Hosts
if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[1], ignore_type === 'redirection' && ignore_id ? ignore_id : 0)) {
is_taken = true;
}
}
if (promises_results[2]) {
// Dead Hosts
if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[2], ignore_type === 'dead' && ignore_id ? ignore_id : 0)) {
is_taken = true;
}
}
return {
hostname: hostname,
is_taken: is_taken
};
});
},
/**
* Private call only
*
* @param {String} hostname
* @param {Array} existing_rows
* @param {Integer} [ignore_id]
* @returns {Boolean}
*/
_checkHostnameRecordsTaken: function (hostname, existing_rows, ignore_id) {
let is_taken = false;
if (existing_rows && existing_rows.length) {
existing_rows.map(function (existing_row) {
existing_row.domain_names.map(function (existing_hostname) {
// Does this domain match?
if (existing_hostname.toLowerCase() === hostname.toLowerCase()) {
if (!ignore_id || ignore_id !== existing_row.id) {
is_taken = true;
}
}
});
});
}
return is_taken;
},
/**
* Private call only
*
* @param {Array} hosts
* @param {Array} domain_names
* @returns {Array}
*/
_getHostsWithDomains: function (hosts, domain_names) {
let response = [];
if (hosts && hosts.length) {
hosts.map(function (host) {
let host_matches = false;
domain_names.map(function (domain_name) {
host.domain_names.map(function (host_domain_name) {
if (domain_name.toLowerCase() === host_domain_name.toLowerCase()) {
host_matches = true;
}
});
});
if (host_matches) {
response.push(host);
}
});
}
return response;
}
};
module.exports = internalHost;
-147
View File
@@ -1,147 +0,0 @@
const https = require('https');
const fs = require('fs');
const logger = require('../logger').ip_ranges;
const error = require('../lib/error');
const utils = require('../lib/utils');
const internalNginx = require('./nginx');
const CLOUDFRONT_URL = 'https://ip-ranges.amazonaws.com/ip-ranges.json';
const CLOUDFARE_V4_URL = 'https://www.cloudflare.com/ips-v4';
const CLOUDFARE_V6_URL = 'https://www.cloudflare.com/ips-v6';
const regIpV4 = /^(\d+\.?){4}\/\d+/;
const regIpV6 = /^(([\da-fA-F]+)?:)+\/\d+/;
const internalIpRanges = {
interval_timeout: 1000 * 60 * 60 * 6, // 6 hours
interval: null,
interval_processing: false,
iteration_count: 0,
initTimer: () => {
logger.info('IP Ranges Renewal Timer initialized');
internalIpRanges.interval = setInterval(internalIpRanges.fetch, internalIpRanges.interval_timeout);
},
fetchUrl: (url) => {
return new Promise((resolve, reject) => {
logger.info('Fetching ' + url);
return https.get(url, (res) => {
res.setEncoding('utf8');
let raw_data = '';
res.on('data', (chunk) => {
raw_data += chunk;
});
res.on('end', () => {
resolve(raw_data);
});
}).on('error', (err) => {
reject(err);
});
});
},
/**
* Triggered at startup and then later by a timer, this will fetch the ip ranges from services and apply them to nginx.
*/
fetch: () => {
if (!internalIpRanges.interval_processing) {
internalIpRanges.interval_processing = true;
logger.info('Fetching IP Ranges from online services...');
let ip_ranges = [];
return internalIpRanges.fetchUrl(CLOUDFRONT_URL)
.then((cloudfront_data) => {
let data = JSON.parse(cloudfront_data);
if (data && typeof data.prefixes !== 'undefined') {
data.prefixes.map((item) => {
if (item.service === 'CLOUDFRONT') {
ip_ranges.push(item.ip_prefix);
}
});
}
if (data && typeof data.ipv6_prefixes !== 'undefined') {
data.ipv6_prefixes.map((item) => {
if (item.service === 'CLOUDFRONT') {
ip_ranges.push(item.ipv6_prefix);
}
});
}
})
.then(() => {
return internalIpRanges.fetchUrl(CLOUDFARE_V4_URL);
})
.then((cloudfare_data) => {
let items = cloudfare_data.split('\n').filter((line) => regIpV4.test(line));
ip_ranges = [... ip_ranges, ... items];
})
.then(() => {
return internalIpRanges.fetchUrl(CLOUDFARE_V6_URL);
})
.then((cloudfare_data) => {
let items = cloudfare_data.split('\n').filter((line) => regIpV6.test(line));
ip_ranges = [... ip_ranges, ... items];
})
.then(() => {
let clean_ip_ranges = [];
ip_ranges.map((range) => {
if (range) {
clean_ip_ranges.push(range);
}
});
return internalIpRanges.generateConfig(clean_ip_ranges)
.then(() => {
if (internalIpRanges.iteration_count) {
// Reload nginx
return internalNginx.reload();
}
});
})
.then(() => {
internalIpRanges.interval_processing = false;
internalIpRanges.iteration_count++;
})
.catch((err) => {
logger.error(err.message);
internalIpRanges.interval_processing = false;
});
}
},
/**
* @param {Array} ip_ranges
* @returns {Promise}
*/
generateConfig: (ip_ranges) => {
const renderEngine = utils.getRenderEngine();
return new Promise((resolve, reject) => {
let template = null;
let filename = '/etc/nginx/conf.d/include/ip_ranges.conf';
try {
template = fs.readFileSync(__dirname + '/../templates/ip_ranges.conf', {encoding: 'utf8'});
} catch (err) {
reject(new error.ConfigurationError(err.message));
return;
}
renderEngine
.parseAndRender(template, {ip_ranges: ip_ranges})
.then((config_text) => {
fs.writeFileSync(filename, config_text, {encoding: 'utf8'});
resolve(true);
})
.catch((err) => {
logger.warn('Could not write ' + filename + ':', err.message);
reject(new error.ConfigurationError(err.message));
});
});
}
};
module.exports = internalIpRanges;
-436
View File
@@ -1,436 +0,0 @@
const _ = require('lodash');
const fs = require('node:fs');
const logger = require('../logger').nginx;
const config = require('../lib/config');
const utils = require('../lib/utils');
const error = require('../lib/error');
const internalNginx = {
/**
* This will:
* - test the nginx config first to make sure it's OK
* - create / recreate the config for the host
* - test again
* - IF OK: update the meta with online status
* - IF BAD: update the meta with offline status and remove the config entirely
* - then reload nginx
*
* @param {Object|String} model
* @param {String} host_type
* @param {Object} host
* @returns {Promise}
*/
configure: (model, host_type, host) => {
let combined_meta = {};
return internalNginx.test()
.then(() => {
// Nginx is OK
// We're deleting this config regardless.
// Don't throw errors, as the file may not exist at all
// Delete the .err file too
return internalNginx.deleteConfig(host_type, host, false, true);
})
.then(() => {
return internalNginx.generateConfig(host_type, host);
})
.then(() => {
// Test nginx again and update meta with result
return internalNginx.test()
.then(() => {
// nginx is ok
combined_meta = _.assign({}, host.meta, {
nginx_online: true,
nginx_err: null
});
return model
.query()
.where('id', host.id)
.patch({
meta: combined_meta
});
})
.catch((err) => {
// Remove the error_log line because it's a docker-ism false positive that doesn't need to be reported.
// It will always look like this:
// nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (6: No such device or address)
const valid_lines = [];
const err_lines = err.message.split('\n');
err_lines.map((line) => {
if (line.indexOf('/var/log/nginx/error.log') === -1) {
valid_lines.push(line);
}
});
if (config.debug()) {
logger.error('Nginx test failed:', valid_lines.join('\n'));
}
// config is bad, update meta and delete config
combined_meta = _.assign({}, host.meta, {
nginx_online: false,
nginx_err: valid_lines.join('\n')
});
return model
.query()
.where('id', host.id)
.patch({
meta: combined_meta
})
.then(() => {
internalNginx.renameConfigAsError(host_type, host);
})
.then(() => {
return internalNginx.deleteConfig(host_type, host, true);
});
});
})
.then(() => {
return internalNginx.reload();
})
.then(() => {
return combined_meta;
});
},
/**
* @returns {Promise}
*/
test: () => {
if (config.debug()) {
logger.info('Testing Nginx configuration');
}
return utils.execFile('/usr/sbin/nginx', ['-t', '-g', 'error_log off;']);
},
/**
* @returns {Promise}
*/
reload: () => {
return internalNginx.test()
.then(() => {
logger.info('Reloading Nginx');
return utils.execFile('/usr/sbin/nginx', ['-s', 'reload']);
});
},
/**
* @param {String} host_type
* @param {Integer} host_id
* @returns {String}
*/
getConfigName: (host_type, host_id) => {
if (host_type === 'default') {
return '/data/nginx/default_host/site.conf';
}
return `/data/nginx/${internalNginx.getFileFriendlyHostType(host_type)}/${host_id}.conf`;
},
/**
* Generates custom locations
* @param {Object} host
* @returns {Promise}
*/
renderLocations: (host) => {
return new Promise((resolve, reject) => {
let template;
try {
template = fs.readFileSync(`${__dirname}/../templates/_location.conf`, {encoding: 'utf8'});
} catch (err) {
reject(new error.ConfigurationError(err.message));
return;
}
const renderEngine = utils.getRenderEngine();
let renderedLocations = '';
const locationRendering = async () => {
for (let i = 0; i < host.locations.length; i++) {
const locationCopy = Object.assign({}, {access_list_id: host.access_list_id}, {certificate_id: host.certificate_id},
{ssl_forced: host.ssl_forced}, {caching_enabled: host.caching_enabled}, {block_exploits: host.block_exploits},
{allow_websocket_upgrade: host.allow_websocket_upgrade}, {http2_support: host.http2_support},
{hsts_enabled: host.hsts_enabled}, {hsts_subdomains: host.hsts_subdomains}, {access_list: host.access_list},
{certificate: host.certificate}, host.locations[i]);
if (locationCopy.forward_host.indexOf('/') > -1) {
const splitted = locationCopy.forward_host.split('/');
locationCopy.forward_host = splitted.shift();
locationCopy.forward_path = `/${splitted.join('/')}`;
}
// eslint-disable-next-line
renderedLocations += await renderEngine.parseAndRender(template, locationCopy);
}
};
locationRendering().then(() => resolve(renderedLocations));
});
},
/**
* @param {String} host_type
* @param {Object} host
* @returns {Promise}
*/
generateConfig: (host_type, host_row) => {
// Prevent modifying the original object:
const host = JSON.parse(JSON.stringify(host_row));
const nice_host_type = internalNginx.getFileFriendlyHostType(host_type);
if (config.debug()) {
logger.info(`Generating ${nice_host_type} Config:`, JSON.stringify(host, null, 2));
}
const renderEngine = utils.getRenderEngine();
return new Promise((resolve, reject) => {
let template = null;
const filename = internalNginx.getConfigName(nice_host_type, host.id);
try {
template = fs.readFileSync(`${__dirname}/../templates/${nice_host_type}.conf`, {encoding: 'utf8'});
} catch (err) {
reject(new error.ConfigurationError(err.message));
return;
}
let locationsPromise;
let origLocations;
// Manipulate the data a bit before sending it to the template
if (nice_host_type !== 'default') {
host.use_default_location = true;
if (typeof host.advanced_config !== 'undefined' && host.advanced_config) {
host.use_default_location = !internalNginx.advancedConfigHasDefaultLocation(host.advanced_config);
}
}
if (host.locations) {
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
origLocations = [].concat(host.locations);
locationsPromise = internalNginx.renderLocations(host).then((renderedLocations) => {
host.locations = renderedLocations;
});
// Allow someone who is using / custom location path to use it, and skip the default / location
_.map(host.locations, (location) => {
if (location.path === '/') {
host.use_default_location = false;
}
});
} else {
locationsPromise = Promise.resolve();
}
// Set the IPv6 setting for the host
host.ipv6 = internalNginx.ipv6Enabled();
locationsPromise.then(() => {
renderEngine
.parseAndRender(template, host)
.then((config_text) => {
fs.writeFileSync(filename, config_text, {encoding: 'utf8'});
if (config.debug()) {
logger.success('Wrote config:', filename, config_text);
}
// Restore locations array
host.locations = origLocations;
resolve(true);
})
.catch((err) => {
if (config.debug()) {
logger.warn(`Could not write ${filename}:`, err.message);
}
reject(new error.ConfigurationError(err.message));
});
});
});
},
/**
* This generates a temporary nginx config listening on port 80 for the domain names listed
* in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt
* when requesting a certificate without having a hostname set up already.
*
* @param {Object} certificate
* @returns {Promise}
*/
generateLetsEncryptRequestConfig: (certificate) => {
if (config.debug()) {
logger.info('Generating LetsEncrypt Request Config:', certificate);
}
const renderEngine = utils.getRenderEngine();
return new Promise((resolve, reject) => {
let template = null;
const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
try {
template = fs.readFileSync(`${__dirname}/../templates/letsencrypt-request.conf`, {encoding: 'utf8'});
} catch (err) {
reject(new error.ConfigurationError(err.message));
return;
}
certificate.ipv6 = internalNginx.ipv6Enabled();
renderEngine
.parseAndRender(template, certificate)
.then((config_text) => {
fs.writeFileSync(filename, config_text, {encoding: 'utf8'});
if (config.debug()) {
logger.success('Wrote config:', filename, config_text);
}
resolve(true);
})
.catch((err) => {
if (config.debug()) {
logger.warn(`Could not write ${filename}:`, err.message);
}
reject(new error.ConfigurationError(err.message));
});
});
},
/**
* A simple wrapper around unlinkSync that writes to the logger
*
* @param {String} filename
*/
deleteFile: (filename) => {
logger.debug(`Deleting file: ${filename}`);
try {
fs.unlinkSync(filename);
} catch (err) {
logger.debug('Could not delete file:', JSON.stringify(err, null, 2));
}
},
/**
*
* @param {String} host_type
* @returns String
*/
getFileFriendlyHostType: (host_type) => {
return host_type.replace(/-/g, '_');
},
/**
* This removes the temporary nginx config file generated by `generateLetsEncryptRequestConfig`
*
* @param {Object} certificate
* @returns {Promise}
*/
deleteLetsEncryptRequestConfig: (certificate) => {
const config_file = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
return new Promise((resolve/*, reject*/) => {
internalNginx.deleteFile(config_file);
resolve();
});
},
/**
* @param {String} host_type
* @param {Object} [host]
* @param {Boolean} [delete_err_file]
* @returns {Promise}
*/
deleteConfig: (host_type, host, delete_err_file) => {
const config_file = internalNginx.getConfigName(internalNginx.getFileFriendlyHostType(host_type), typeof host === 'undefined' ? 0 : host.id);
const config_file_err = `${config_file}.err`;
return new Promise((resolve/*, reject*/) => {
internalNginx.deleteFile(config_file);
if (delete_err_file) {
internalNginx.deleteFile(config_file_err);
}
resolve();
});
},
/**
* @param {String} host_type
* @param {Object} [host]
* @returns {Promise}
*/
renameConfigAsError: (host_type, host) => {
const config_file = internalNginx.getConfigName(internalNginx.getFileFriendlyHostType(host_type), typeof host === 'undefined' ? 0 : host.id);
const config_file_err = `${config_file}.err`;
return new Promise((resolve/*, reject*/) => {
fs.unlink(config_file, () => {
// ignore result, continue
fs.rename(config_file, config_file_err, () => {
// also ignore result, as this is a debugging informative file anyway
resolve();
});
});
});
},
/**
* @param {String} host_type
* @param {Array} hosts
* @returns {Promise}
*/
bulkGenerateConfigs: (host_type, hosts) => {
const promises = [];
hosts.map((host) => {
promises.push(internalNginx.generateConfig(host_type, host));
});
return Promise.all(promises);
},
/**
* @param {String} host_type
* @param {Array} hosts
* @returns {Promise}
*/
bulkDeleteConfigs: (host_type, hosts) => {
const promises = [];
hosts.map((host) => {
promises.push(internalNginx.deleteConfig(host_type, host, true));
});
return Promise.all(promises);
},
/**
* @param {string} config
* @returns {boolean}
*/
advancedConfigHasDefaultLocation: (cfg) => !!cfg.match(/^(?:.*;)?\s*?location\s*?\/\s*?{/im),
/**
* @returns {boolean}
*/
ipv6Enabled: () => {
if (typeof process.env.DISABLE_IPV6 !== 'undefined') {
const disabled = process.env.DISABLE_IPV6.toLowerCase();
return !(disabled === 'on' || disabled === 'true' || disabled === '1' || disabled === 'yes');
}
return true;
}
};
module.exports = internalNginx;
-472
View File
@@ -1,472 +0,0 @@
const _ = require('lodash');
const error = require('../lib/error');
const utils = require('../lib/utils');
const proxyHostModel = require('../models/proxy_host');
const internalHost = require('./host');
const internalNginx = require('./nginx');
const internalAuditLog = require('./audit-log');
const internalCertificate = require('./certificate');
const {castJsonIfNeed} = require('../lib/helpers');
function omissions () {
return ['is_deleted', 'owner.is_deleted'];
}
const internalProxyHost = {
/**
* @param {Access} access
* @param {Object} data
* @returns {Promise}
*/
create: (access, data) => {
let create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('proxy_hosts:create', data)
.then(() => {
// Get a list of the domain names and check each of them against existing records
let domain_name_check_promises = [];
data.domain_names.map(function (domain_name) {
domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name));
});
return Promise.all(domain_name_check_promises)
.then((check_results) => {
check_results.map(function (result) {
if (result.is_taken) {
throw new error.ValidationError(result.hostname + ' is already in use');
}
});
});
})
.then(() => {
// At this point the domains should have been checked
data.owner_user_id = access.token.getUserId(1);
data = internalHost.cleanSslHstsData(data);
// Fix for db field not having a default value
// for this optional field.
if (typeof data.advanced_config === 'undefined') {
data.advanced_config = '';
}
return proxyHostModel
.query()
.insertAndFetch(data)
.then(utils.omitRow(omissions()));
})
.then((row) => {
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, data)
.then((cert) => {
// update host with cert id
return internalProxyHost.update(access, {
id: row.id,
certificate_id: cert.id
});
})
.then(() => {
return row;
});
} else {
return row;
}
})
.then((row) => {
// re-fetch with cert
return internalProxyHost.get(access, {
id: row.id,
expand: ['certificate', 'owner', 'access_list.[clients,items]']
});
})
.then((row) => {
// Configure nginx
return internalNginx.configure(proxyHostModel, 'proxy_host', row)
.then(() => {
return row;
});
})
.then((row) => {
// Audit log
data.meta = _.assign({}, data.meta || {}, row.meta);
// Add to audit log
return internalAuditLog.add(access, {
action: 'created',
object_type: 'proxy-host',
object_id: row.id,
meta: data
})
.then(() => {
return row;
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @return {Promise}
*/
update: (access, data) => {
let create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('proxy_hosts:update', data.id)
.then((/*access_data*/) => {
// Get a list of the domain names and check each of them against existing records
let domain_name_check_promises = [];
if (typeof data.domain_names !== 'undefined') {
data.domain_names.map(function (domain_name) {
domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name, 'proxy', data.id));
});
return Promise.all(domain_name_check_promises)
.then((check_results) => {
check_results.map(function (result) {
if (result.is_taken) {
throw new error.ValidationError(result.hostname + ' is already in use');
}
});
});
}
})
.then(() => {
return internalProxyHost.get(access, {id: data.id});
})
.then((row) => {
if (row.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('Proxy Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
}
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, {
domain_names: data.domain_names || row.domain_names,
meta: _.assign({}, row.meta, data.meta)
})
.then((cert) => {
// update host with cert id
data.certificate_id = cert.id;
})
.then(() => {
return row;
});
} else {
return row;
}
})
.then((row) => {
// Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here.
data = _.assign({}, {
domain_names: row.domain_names
}, data);
data = internalHost.cleanSslHstsData(data, row);
return proxyHostModel
.query()
.where({id: data.id})
.patch(data)
.then(utils.omitRow(omissions()))
.then((saved_row) => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'proxy-host',
object_id: row.id,
meta: data
})
.then(() => {
return saved_row;
});
});
})
.then(() => {
return internalProxyHost.get(access, {
id: data.id,
expand: ['owner', 'certificate', 'access_list.[clients,items]']
})
.then((row) => {
if (!row.enabled) {
// No need to add nginx config if host is disabled
return row;
}
// Configure nginx
return internalNginx.configure(proxyHostModel, 'proxy_host', row)
.then((new_meta) => {
row.meta = new_meta;
row = internalHost.cleanRowCertificateMeta(row);
return _.omit(row, omissions());
});
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {Array} [data.expand]
* @param {Array} [data.omit]
* @return {Promise}
*/
get: (access, data) => {
if (typeof data === 'undefined') {
data = {};
}
return access.can('proxy_hosts:get', data.id)
.then((access_data) => {
let query = proxyHostModel
.query()
.where('is_deleted', 0)
.andWhere('id', data.id)
.allowGraph('[owner,access_list.[clients,items],certificate]')
.first();
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
if (typeof data.expand !== 'undefined' && data.expand !== null) {
query.withGraphFetched('[' + data.expand.join(', ') + ']');
}
return query.then(utils.omitRow(omissions()));
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
row = internalHost.cleanRowCertificateMeta(row);
// Custom omissions
if (typeof data.omit !== 'undefined' && data.omit !== null) {
row = _.omit(row, data.omit);
}
return row;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
delete: (access, data) => {
return access.can('proxy_hosts:delete', data.id)
.then(() => {
return internalProxyHost.get(access, {id: data.id});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
return proxyHostModel
.query()
.where('id', row.id)
.patch({
is_deleted: 1
})
.then(() => {
// Delete Nginx Config
return internalNginx.deleteConfig('proxy_host', row)
.then(() => {
return internalNginx.reload();
});
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'deleted',
object_type: 'proxy-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
enable: (access, data) => {
return access.can('proxy_hosts:update', data.id)
.then(() => {
return internalProxyHost.get(access, {
id: data.id,
expand: ['certificate', 'owner', 'access_list']
});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
} else if (row.enabled) {
throw new error.ValidationError('Host is already enabled');
}
row.enabled = 1;
return proxyHostModel
.query()
.where('id', row.id)
.patch({
enabled: 1
})
.then(() => {
// Configure nginx
return internalNginx.configure(proxyHostModel, 'proxy_host', row);
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'enabled',
object_type: 'proxy-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
disable: (access, data) => {
return access.can('proxy_hosts:update', data.id)
.then(() => {
return internalProxyHost.get(access, {id: data.id});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
} else if (!row.enabled) {
throw new error.ValidationError('Host is already disabled');
}
row.enabled = 0;
return proxyHostModel
.query()
.where('id', row.id)
.patch({
enabled: 0
})
.then(() => {
// Delete Nginx Config
return internalNginx.deleteConfig('proxy_host', row)
.then(() => {
return internalNginx.reload();
});
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'disabled',
object_type: 'proxy-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* All Hosts
*
* @param {Access} access
* @param {Array} [expand]
* @param {String} [search_query]
* @returns {Promise}
*/
getAll: (access, expand, search_query) => {
return access.can('proxy_hosts:list')
.then((access_data) => {
let query = proxyHostModel
.query()
.where('is_deleted', 0)
.groupBy('id')
.allowGraph('[owner,access_list,certificate]')
.orderBy(castJsonIfNeed('domain_names'), 'ASC');
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
// Query is used for searching
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where(castJsonIfNeed('domain_names'), 'like', `%${search_query}%`);
});
}
if (typeof expand !== 'undefined' && expand !== null) {
query.withGraphFetched('[' + expand.join(', ') + ']');
}
return query.then(utils.omitRows(omissions()));
})
.then((rows) => {
if (typeof expand !== 'undefined' && expand !== null && expand.indexOf('certificate') !== -1) {
return internalHost.cleanAllRowsCertificateMeta(rows);
}
return rows;
});
},
/**
* Report use
*
* @param {Number} user_id
* @param {String} visibility
* @returns {Promise}
*/
getCount: (user_id, visibility) => {
let query = proxyHostModel
.query()
.count('id as count')
.where('is_deleted', 0);
if (visibility !== 'all') {
query.andWhere('owner_user_id', user_id);
}
return query.first()
.then((row) => {
return parseInt(row.count, 10);
});
}
};
module.exports = internalProxyHost;
-465
View File
@@ -1,465 +0,0 @@
const _ = require('lodash');
const error = require('../lib/error');
const utils = require('../lib/utils');
const redirectionHostModel = require('../models/redirection_host');
const internalHost = require('./host');
const internalNginx = require('./nginx');
const internalAuditLog = require('./audit-log');
const internalCertificate = require('./certificate');
const {castJsonIfNeed} = require('../lib/helpers');
function omissions () {
return ['is_deleted'];
}
const internalRedirectionHost = {
/**
* @param {Access} access
* @param {Object} data
* @returns {Promise}
*/
create: (access, data) => {
let create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('redirection_hosts:create', data)
.then((/*access_data*/) => {
// Get a list of the domain names and check each of them against existing records
let domain_name_check_promises = [];
data.domain_names.map(function (domain_name) {
domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name));
});
return Promise.all(domain_name_check_promises)
.then((check_results) => {
check_results.map(function (result) {
if (result.is_taken) {
throw new error.ValidationError(result.hostname + ' is already in use');
}
});
});
})
.then(() => {
// At this point the domains should have been checked
data.owner_user_id = access.token.getUserId(1);
data = internalHost.cleanSslHstsData(data);
// Fix for db field not having a default value
// for this optional field.
if (typeof data.advanced_config === 'undefined') {
data.advanced_config = '';
}
return redirectionHostModel
.query()
.insertAndFetch(data)
.then(utils.omitRow(omissions()));
})
.then((row) => {
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, data)
.then((cert) => {
// update host with cert id
return internalRedirectionHost.update(access, {
id: row.id,
certificate_id: cert.id
});
})
.then(() => {
return row;
});
}
return row;
})
.then((row) => {
// re-fetch with cert
return internalRedirectionHost.get(access, {
id: row.id,
expand: ['certificate', 'owner']
});
})
.then((row) => {
// Configure nginx
return internalNginx.configure(redirectionHostModel, 'redirection_host', row)
.then(() => {
return row;
});
})
.then((row) => {
data.meta = _.assign({}, data.meta || {}, row.meta);
// Add to audit log
return internalAuditLog.add(access, {
action: 'created',
object_type: 'redirection-host',
object_id: row.id,
meta: data
})
.then(() => {
return row;
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @return {Promise}
*/
update: (access, data) => {
let create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('redirection_hosts:update', data.id)
.then((/*access_data*/) => {
// Get a list of the domain names and check each of them against existing records
let domain_name_check_promises = [];
if (typeof data.domain_names !== 'undefined') {
data.domain_names.map(function (domain_name) {
domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name, 'redirection', data.id));
});
return Promise.all(domain_name_check_promises)
.then((check_results) => {
check_results.map(function (result) {
if (result.is_taken) {
throw new error.ValidationError(result.hostname + ' is already in use');
}
});
});
}
})
.then(() => {
return internalRedirectionHost.get(access, {id: data.id});
})
.then((row) => {
if (row.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('Redirection Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
}
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, {
domain_names: data.domain_names || row.domain_names,
meta: _.assign({}, row.meta, data.meta)
})
.then((cert) => {
// update host with cert id
data.certificate_id = cert.id;
})
.then(() => {
return row;
});
} else {
return row;
}
})
.then((row) => {
// Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here.
data = _.assign({}, {
domain_names: row.domain_names
}, data);
data = internalHost.cleanSslHstsData(data, row);
return redirectionHostModel
.query()
.where({id: data.id})
.patch(data)
.then((saved_row) => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'redirection-host',
object_id: row.id,
meta: data
})
.then(() => {
return _.omit(saved_row, omissions());
});
});
})
.then(() => {
return internalRedirectionHost.get(access, {
id: data.id,
expand: ['owner', 'certificate']
})
.then((row) => {
// Configure nginx
return internalNginx.configure(redirectionHostModel, 'redirection_host', row)
.then((new_meta) => {
row.meta = new_meta;
row = internalHost.cleanRowCertificateMeta(row);
return _.omit(row, omissions());
});
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {Array} [data.expand]
* @param {Array} [data.omit]
* @return {Promise}
*/
get: (access, data) => {
if (typeof data === 'undefined') {
data = {};
}
return access.can('redirection_hosts:get', data.id)
.then((access_data) => {
let query = redirectionHostModel
.query()
.where('is_deleted', 0)
.andWhere('id', data.id)
.allowGraph('[owner,certificate]')
.first();
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
if (typeof data.expand !== 'undefined' && data.expand !== null) {
query.withGraphFetched('[' + data.expand.join(', ') + ']');
}
return query.then(utils.omitRow(omissions()));
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
row = internalHost.cleanRowCertificateMeta(row);
// Custom omissions
if (typeof data.omit !== 'undefined' && data.omit !== null) {
row = _.omit(row, data.omit);
}
return row;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
delete: (access, data) => {
return access.can('redirection_hosts:delete', data.id)
.then(() => {
return internalRedirectionHost.get(access, {id: data.id});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
return redirectionHostModel
.query()
.where('id', row.id)
.patch({
is_deleted: 1
})
.then(() => {
// Delete Nginx Config
return internalNginx.deleteConfig('redirection_host', row)
.then(() => {
return internalNginx.reload();
});
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'deleted',
object_type: 'redirection-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
enable: (access, data) => {
return access.can('redirection_hosts:update', data.id)
.then(() => {
return internalRedirectionHost.get(access, {
id: data.id,
expand: ['certificate', 'owner']
});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
} else if (row.enabled) {
throw new error.ValidationError('Host is already enabled');
}
row.enabled = 1;
return redirectionHostModel
.query()
.where('id', row.id)
.patch({
enabled: 1
})
.then(() => {
// Configure nginx
return internalNginx.configure(redirectionHostModel, 'redirection_host', row);
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'enabled',
object_type: 'redirection-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
disable: (access, data) => {
return access.can('redirection_hosts:update', data.id)
.then(() => {
return internalRedirectionHost.get(access, {id: data.id});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
} else if (!row.enabled) {
throw new error.ValidationError('Host is already disabled');
}
row.enabled = 0;
return redirectionHostModel
.query()
.where('id', row.id)
.patch({
enabled: 0
})
.then(() => {
// Delete Nginx Config
return internalNginx.deleteConfig('redirection_host', row)
.then(() => {
return internalNginx.reload();
});
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'disabled',
object_type: 'redirection-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* All Hosts
*
* @param {Access} access
* @param {Array} [expand]
* @param {String} [search_query]
* @returns {Promise}
*/
getAll: (access, expand, search_query) => {
return access.can('redirection_hosts:list')
.then((access_data) => {
let query = redirectionHostModel
.query()
.where('is_deleted', 0)
.groupBy('id')
.allowGraph('[owner,certificate]')
.orderBy(castJsonIfNeed('domain_names'), 'ASC');
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
// Query is used for searching
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where(castJsonIfNeed('domain_names'), 'like', `%${search_query}%`);
});
}
if (typeof expand !== 'undefined' && expand !== null) {
query.withGraphFetched('[' + expand.join(', ') + ']');
}
return query.then(utils.omitRows(omissions()));
})
.then((rows) => {
if (typeof expand !== 'undefined' && expand !== null && expand.indexOf('certificate') !== -1) {
return internalHost.cleanAllRowsCertificateMeta(rows);
}
return rows;
});
},
/**
* Report use
*
* @param {Number} user_id
* @param {String} visibility
* @returns {Promise}
*/
getCount: (user_id, visibility) => {
let query = redirectionHostModel
.query()
.count('id as count')
.where('is_deleted', 0);
if (visibility !== 'all') {
query.andWhere('owner_user_id', user_id);
}
return query.first()
.then((row) => {
return parseInt(row.count, 10);
});
}
};
module.exports = internalRedirectionHost;
-38
View File
@@ -1,38 +0,0 @@
const internalProxyHost = require('./proxy-host');
const internalRedirectionHost = require('./redirection-host');
const internalDeadHost = require('./dead-host');
const internalStream = require('./stream');
const internalReport = {
/**
* @param {Access} access
* @return {Promise}
*/
getHostsReport: (access) => {
return access.can('reports:hosts', 1)
.then((access_data) => {
let user_id = access.token.getUserId(1);
let promises = [
internalProxyHost.getCount(user_id, access_data.visibility),
internalRedirectionHost.getCount(user_id, access_data.visibility),
internalStream.getCount(user_id, access_data.visibility),
internalDeadHost.getCount(user_id, access_data.visibility)
];
return Promise.all(promises);
})
.then((counts) => {
return {
proxy: counts.shift(),
redirection: counts.shift(),
stream: counts.shift(),
dead: counts.shift()
};
});
}
};
module.exports = internalReport;
-133
View File
@@ -1,133 +0,0 @@
const fs = require('fs');
const error = require('../lib/error');
const settingModel = require('../models/setting');
const internalNginx = require('./nginx');
const internalSetting = {
/**
* @param {Access} access
* @param {Object} data
* @param {String} data.id
* @return {Promise}
*/
update: (access, data) => {
return access.can('settings:update', data.id)
.then((/*access_data*/) => {
return internalSetting.get(access, {id: data.id});
})
.then((row) => {
if (row.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('Setting could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
}
return settingModel
.query()
.where({id: data.id})
.patch(data);
})
.then(() => {
return internalSetting.get(access, {
id: data.id
});
})
.then((row) => {
if (row.id === 'default-site') {
// write the html if we need to
if (row.value === 'html') {
fs.writeFileSync('/data/nginx/default_www/index.html', row.meta.html, {encoding: 'utf8'});
}
// Configure nginx
return internalNginx.deleteConfig('default')
.then(() => {
return internalNginx.generateConfig('default', row);
})
.then(() => {
return internalNginx.test();
})
.then(() => {
return internalNginx.reload();
})
.then(() => {
return row;
})
.catch((/*err*/) => {
internalNginx.deleteConfig('default')
.then(() => {
return internalNginx.test();
})
.then(() => {
return internalNginx.reload();
})
.then(() => {
// I'm being slack here I know..
throw new error.ValidationError('Could not reconfigure Nginx. Please check logs.');
});
});
} else {
return row;
}
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {String} data.id
* @return {Promise}
*/
get: (access, data) => {
return access.can('settings:get', data.id)
.then(() => {
return settingModel
.query()
.where('id', data.id)
.first();
})
.then((row) => {
if (row) {
return row;
} else {
throw new error.ItemNotFoundError(data.id);
}
});
},
/**
* This will only count the settings
*
* @param {Access} access
* @returns {*}
*/
getCount: (access) => {
return access.can('settings:list')
.then(() => {
return settingModel
.query()
.count('id as count')
.first();
})
.then((row) => {
return parseInt(row.count, 10);
});
},
/**
* All settings
*
* @param {Access} access
* @returns {Promise}
*/
getAll: (access) => {
return access.can('settings:list')
.then(() => {
return settingModel
.query()
.orderBy('description', 'ASC');
});
}
};
module.exports = internalSetting;
-424
View File
@@ -1,424 +0,0 @@
const _ = require('lodash');
const error = require('../lib/error');
const utils = require('../lib/utils');
const streamModel = require('../models/stream');
const internalNginx = require('./nginx');
const internalAuditLog = require('./audit-log');
const internalCertificate = require('./certificate');
const internalHost = require('./host');
const {castJsonIfNeed} = require('../lib/helpers');
function omissions () {
return ['is_deleted', 'owner.is_deleted', 'certificate.is_deleted'];
}
const internalStream = {
/**
* @param {Access} access
* @param {Object} data
* @returns {Promise}
*/
create: (access, data) => {
const create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('streams:create', data)
.then((/*access_data*/) => {
// TODO: At this point the existing ports should have been checked
data.owner_user_id = access.token.getUserId(1);
if (typeof data.meta === 'undefined') {
data.meta = {};
}
// streams aren't routed by domain name so don't store domain names in the DB
let data_no_domains = structuredClone(data);
delete data_no_domains.domain_names;
return streamModel
.query()
.insertAndFetch(data_no_domains)
.then(utils.omitRow(omissions()));
})
.then((row) => {
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, data)
.then((cert) => {
// update host with cert id
return internalStream.update(access, {
id: row.id,
certificate_id: cert.id
});
})
.then(() => {
return row;
});
} else {
return row;
}
})
.then((row) => {
// re-fetch with cert
return internalStream.get(access, {
id: row.id,
expand: ['certificate', 'owner']
});
})
.then((row) => {
// Configure nginx
return internalNginx.configure(streamModel, 'stream', row)
.then(() => {
return row;
});
})
.then((row) => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'created',
object_type: 'stream',
object_id: row.id,
meta: data
})
.then(() => {
return row;
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @return {Promise}
*/
update: (access, data) => {
const create_certificate = data.certificate_id === 'new';
if (create_certificate) {
delete data.certificate_id;
}
return access.can('streams:update', data.id)
.then((/*access_data*/) => {
// TODO: at this point the existing streams should have been checked
return internalStream.get(access, {id: data.id});
})
.then((row) => {
if (row.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('Stream could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
}
if (create_certificate) {
return internalCertificate.createQuickCertificate(access, {
domain_names: data.domain_names || row.domain_names,
meta: _.assign({}, row.meta, data.meta)
})
.then((cert) => {
// update host with cert id
data.certificate_id = cert.id;
})
.then(() => {
return row;
});
} else {
return row;
}
})
.then((row) => {
// Add domain_names to the data in case it isn't there, so that the audit log renders correctly. The order is important here.
data = _.assign({}, {
domain_names: row.domain_names
}, data);
return streamModel
.query()
.patchAndFetchById(row.id, data)
.then(utils.omitRow(omissions()))
.then((saved_row) => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'stream',
object_id: row.id,
meta: data
})
.then(() => {
return saved_row;
});
});
})
.then(() => {
return internalStream.get(access, {id: data.id, expand: ['owner', 'certificate']})
.then((row) => {
return internalNginx.configure(streamModel, 'stream', row)
.then((new_meta) => {
row.meta = new_meta;
row = internalHost.cleanRowCertificateMeta(row);
return _.omit(row, omissions());
});
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {Array} [data.expand]
* @param {Array} [data.omit]
* @return {Promise}
*/
get: (access, data) => {
if (typeof data === 'undefined') {
data = {};
}
return access.can('streams:get', data.id)
.then((access_data) => {
let query = streamModel
.query()
.where('is_deleted', 0)
.andWhere('id', data.id)
.allowGraph('[owner,certificate]')
.first();
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
if (typeof data.expand !== 'undefined' && data.expand !== null) {
query.withGraphFetched('[' + data.expand.join(', ') + ']');
}
return query.then(utils.omitRow(omissions()));
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
row = internalHost.cleanRowCertificateMeta(row);
// Custom omissions
if (typeof data.omit !== 'undefined' && data.omit !== null) {
row = _.omit(row, data.omit);
}
return row;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
delete: (access, data) => {
return access.can('streams:delete', data.id)
.then(() => {
return internalStream.get(access, {id: data.id});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
return streamModel
.query()
.where('id', row.id)
.patch({
is_deleted: 1
})
.then(() => {
// Delete Nginx Config
return internalNginx.deleteConfig('stream', row)
.then(() => {
return internalNginx.reload();
});
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'deleted',
object_type: 'stream',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
enable: (access, data) => {
return access.can('streams:update', data.id)
.then(() => {
return internalStream.get(access, {
id: data.id,
expand: ['certificate', 'owner']
});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
} else if (row.enabled) {
throw new error.ValidationError('Stream is already enabled');
}
row.enabled = 1;
return streamModel
.query()
.where('id', row.id)
.patch({
enabled: 1
})
.then(() => {
// Configure nginx
return internalNginx.configure(streamModel, 'stream', row);
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'enabled',
object_type: 'stream',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Number} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
disable: (access, data) => {
return access.can('streams:update', data.id)
.then(() => {
return internalStream.get(access, {id: data.id});
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
} else if (!row.enabled) {
throw new error.ValidationError('Stream is already disabled');
}
row.enabled = 0;
return streamModel
.query()
.where('id', row.id)
.patch({
enabled: 0
})
.then(() => {
// Delete Nginx Config
return internalNginx.deleteConfig('stream', row)
.then(() => {
return internalNginx.reload();
});
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'disabled',
object_type: 'stream-host',
object_id: row.id,
meta: _.omit(row, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* All Streams
*
* @param {Access} access
* @param {Array} [expand]
* @param {String} [search_query]
* @returns {Promise}
*/
getAll: (access, expand, search_query) => {
return access.can('streams:list')
.then((access_data) => {
const query = streamModel
.query()
.where('is_deleted', 0)
.groupBy('id')
.allowGraph('[owner,certificate]')
.orderBy('incoming_port', 'ASC');
if (access_data.permission_visibility !== 'all') {
query.andWhere('owner_user_id', access.token.getUserId(1));
}
// Query is used for searching
if (typeof search_query === 'string' && search_query.length > 0) {
query.where(function () {
this.where(castJsonIfNeed('incoming_port'), 'like', `%${search_query}%`);
});
}
if (typeof expand !== 'undefined' && expand !== null) {
query.withGraphFetched('[' + expand.join(', ') + ']');
}
return query.then(utils.omitRows(omissions()));
})
.then((rows) => {
if (typeof expand !== 'undefined' && expand !== null && expand.indexOf('certificate') !== -1) {
return internalHost.cleanAllRowsCertificateMeta(rows);
}
return rows;
});
},
/**
* Report use
*
* @param {Number} user_id
* @param {String} visibility
* @returns {Promise}
*/
getCount: (user_id, visibility) => {
const query = streamModel
.query()
.count('id AS count')
.where('is_deleted', 0);
if (visibility !== 'all') {
query.andWhere('owner_user_id', user_id);
}
return query.first()
.then((row) => {
return parseInt(row.count, 10);
});
}
};
module.exports = internalStream;
-164
View File
@@ -1,164 +0,0 @@
const _ = require('lodash');
const error = require('../lib/error');
const userModel = require('../models/user');
const authModel = require('../models/auth');
const helpers = require('../lib/helpers');
const TokenModel = require('../models/token');
const ERROR_MESSAGE_INVALID_AUTH = 'Invalid email or password';
module.exports = {
/**
* @param {Object} data
* @param {String} data.identity
* @param {String} data.secret
* @param {String} [data.scope]
* @param {String} [data.expiry]
* @param {String} [issuer]
* @returns {Promise}
*/
getTokenFromEmail: (data, issuer) => {
let Token = new TokenModel();
data.scope = data.scope || 'user';
data.expiry = data.expiry || '1d';
return userModel
.query()
.where('email', data.identity.toLowerCase().trim())
.andWhere('is_deleted', 0)
.andWhere('is_disabled', 0)
.first()
.then((user) => {
if (user) {
// Get auth
return authModel
.query()
.where('user_id', '=', user.id)
.where('type', '=', 'password')
.first()
.then((auth) => {
if (auth) {
return auth.verifyPassword(data.secret)
.then((valid) => {
if (valid) {
if (data.scope !== 'user' && _.indexOf(user.roles, data.scope) === -1) {
// The scope requested doesn't exist as a role against the user,
// you shall not pass.
throw new error.AuthError('Invalid scope: ' + data.scope);
}
// Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) {
throw new error.AuthError('Invalid expiry time: ' + data.expiry);
}
return Token.create({
iss: issuer || 'api',
attrs: {
id: user.id
},
scope: [data.scope],
expiresIn: data.expiry
})
.then((signed) => {
return {
token: signed.token,
expires: expiry.toISOString()
};
});
} else {
throw new error.AuthError(ERROR_MESSAGE_INVALID_AUTH);
}
});
} else {
throw new error.AuthError(ERROR_MESSAGE_INVALID_AUTH);
}
});
} else {
throw new error.AuthError(ERROR_MESSAGE_INVALID_AUTH);
}
});
},
/**
* @param {Access} access
* @param {Object} [data]
* @param {String} [data.expiry]
* @param {String} [data.scope] Only considered if existing token scope is admin
* @returns {Promise}
*/
getFreshToken: (access, data) => {
let Token = new TokenModel();
data = data || {};
data.expiry = data.expiry || '1d';
if (access && access.token.getUserId(0)) {
// Create a moment of the expiry expression
let expiry = helpers.parseDatePeriod(data.expiry);
if (expiry === null) {
throw new error.AuthError('Invalid expiry time: ' + data.expiry);
}
let token_attrs = {
id: access.token.getUserId(0)
};
// Only admins can request otherwise scoped tokens
let scope = access.token.get('scope');
if (data.scope && access.token.hasScope('admin')) {
scope = [data.scope];
if (data.scope === 'job-board' || data.scope === 'worker') {
token_attrs.id = 0;
}
}
return Token.create({
iss: 'api',
scope: scope,
attrs: token_attrs,
expiresIn: data.expiry
})
.then((signed) => {
return {
token: signed.token,
expires: expiry.toISOString()
};
});
} else {
throw new error.AssertionFailedError('Existing token contained invalid user data');
}
},
/**
* @param {Object} user
* @returns {Promise}
*/
getTokenFromUser: (user) => {
const expire = '1d';
const Token = new TokenModel();
const expiry = helpers.parseDatePeriod(expire);
return Token.create({
iss: 'api',
attrs: {
id: user.id
},
scope: ['user'],
expiresIn: expire
})
.then((signed) => {
return {
token: signed.token,
expires: expiry.toISOString(),
user: user
};
});
}
};
-513
View File
@@ -1,513 +0,0 @@
const _ = require('lodash');
const error = require('../lib/error');
const utils = require('../lib/utils');
const userModel = require('../models/user');
const userPermissionModel = require('../models/user_permission');
const authModel = require('../models/auth');
const gravatar = require('gravatar');
const internalToken = require('./token');
const internalAuditLog = require('./audit-log');
function omissions () {
return ['is_deleted'];
}
const internalUser = {
/**
* @param {Access} access
* @param {Object} data
* @returns {Promise}
*/
create: (access, data) => {
let auth = data.auth || null;
delete data.auth;
data.avatar = data.avatar || '';
data.roles = data.roles || [];
if (typeof data.is_disabled !== 'undefined') {
data.is_disabled = data.is_disabled ? 1 : 0;
}
return access.can('users:create', data)
.then(() => {
data.avatar = gravatar.url(data.email, {default: 'mm'});
return userModel
.query()
.insertAndFetch(data)
.then(utils.omitRow(omissions()));
})
.then((user) => {
if (auth) {
return authModel
.query()
.insert({
user_id: user.id,
type: auth.type,
secret: auth.secret,
meta: {}
})
.then(() => {
return user;
});
} else {
return user;
}
})
.then((user) => {
// Create permissions row as well
let is_admin = data.roles.indexOf('admin') !== -1;
return userPermissionModel
.query()
.insert({
user_id: user.id,
visibility: is_admin ? 'all' : 'user',
proxy_hosts: 'manage',
redirection_hosts: 'manage',
dead_hosts: 'manage',
streams: 'manage',
access_lists: 'manage',
certificates: 'manage'
})
.then(() => {
return internalUser.get(access, {id: user.id, expand: ['permissions']});
});
})
.then((user) => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'created',
object_type: 'user',
object_id: user.id,
meta: user
})
.then(() => {
return user;
});
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} [data.email]
* @param {String} [data.name]
* @return {Promise}
*/
update: (access, data) => {
if (typeof data.is_disabled !== 'undefined') {
data.is_disabled = data.is_disabled ? 1 : 0;
}
return access.can('users:update', data.id)
.then(() => {
// Make sure that the user being updated doesn't change their email to another user that is already using it
// 1. get user we want to update
return internalUser.get(access, {id: data.id})
.then((user) => {
// 2. if email is to be changed, find other users with that email
if (typeof data.email !== 'undefined') {
data.email = data.email.toLowerCase().trim();
if (user.email !== data.email) {
return internalUser.isEmailAvailable(data.email, data.id)
.then((available) => {
if (!available) {
throw new error.ValidationError('Email address already in use - ' + data.email);
}
return user;
});
}
}
// No change to email:
return user;
});
})
.then((user) => {
if (user.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id);
}
data.avatar = gravatar.url(data.email || user.email, {default: 'mm'});
return userModel
.query()
.patchAndFetchById(user.id, data)
.then(utils.omitRow(omissions()));
})
.then(() => {
return internalUser.get(access, {id: data.id});
})
.then((user) => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'user',
object_id: user.id,
meta: data
})
.then(() => {
return user;
});
});
},
/**
* @param {Access} access
* @param {Object} [data]
* @param {Integer} [data.id] Defaults to the token user
* @param {Array} [data.expand]
* @param {Array} [data.omit]
* @return {Promise}
*/
get: (access, data) => {
if (typeof data === 'undefined') {
data = {};
}
if (typeof data.id === 'undefined' || !data.id) {
data.id = access.token.getUserId(0);
}
return access.can('users:get', data.id)
.then(() => {
let query = userModel
.query()
.where('is_deleted', 0)
.andWhere('id', data.id)
.allowGraph('[permissions]')
.first();
if (typeof data.expand !== 'undefined' && data.expand !== null) {
query.withGraphFetched('[' + data.expand.join(', ') + ']');
}
return query.then(utils.omitRow(omissions()));
})
.then((row) => {
if (!row || !row.id) {
throw new error.ItemNotFoundError(data.id);
}
// Custom omissions
if (typeof data.omit !== 'undefined' && data.omit !== null) {
row = _.omit(row, data.omit);
}
return row;
});
},
/**
* Checks if an email address is available, but if a user_id is supplied, it will ignore checking
* against that user.
*
* @param email
* @param user_id
*/
isEmailAvailable: (email, user_id) => {
let query = userModel
.query()
.where('email', '=', email.toLowerCase().trim())
.where('is_deleted', 0)
.first();
if (typeof user_id !== 'undefined') {
query.where('id', '!=', user_id);
}
return query
.then((user) => {
return !user;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} [data.reason]
* @returns {Promise}
*/
delete: (access, data) => {
return access.can('users:delete', data.id)
.then(() => {
return internalUser.get(access, {id: data.id});
})
.then((user) => {
if (!user) {
throw new error.ItemNotFoundError(data.id);
}
// Make sure user can't delete themselves
if (user.id === access.token.getUserId(0)) {
throw new error.PermissionError('You cannot delete yourself.');
}
return userModel
.query()
.where('id', user.id)
.patch({
is_deleted: 1
})
.then(() => {
// Add to audit log
return internalAuditLog.add(access, {
action: 'deleted',
object_type: 'user',
object_id: user.id,
meta: _.omit(user, omissions())
});
});
})
.then(() => {
return true;
});
},
/**
* This will only count the users
*
* @param {Access} access
* @param {String} [search_query]
* @returns {*}
*/
getCount: (access, search_query) => {
return access.can('users:list')
.then(() => {
let query = userModel
.query()
.count('id as count')
.where('is_deleted', 0)
.first();
// Query is used for searching
if (typeof search_query === 'string') {
query.where(function () {
this.where('user.name', 'like', '%' + search_query + '%')
.orWhere('user.email', 'like', '%' + search_query + '%');
});
}
return query;
})
.then((row) => {
return parseInt(row.count, 10);
});
},
/**
* All users
*
* @param {Access} access
* @param {Array} [expand]
* @param {String} [search_query]
* @returns {Promise}
*/
getAll: (access, expand, search_query) => {
return access.can('users:list')
.then(() => {
let query = userModel
.query()
.where('is_deleted', 0)
.groupBy('id')
.allowGraph('[permissions]')
.orderBy('name', 'ASC');
// Query is used for searching
if (typeof search_query === 'string') {
query.where(function () {
this.where('name', 'like', '%' + search_query + '%')
.orWhere('email', 'like', '%' + search_query + '%');
});
}
if (typeof expand !== 'undefined' && expand !== null) {
query.withGraphFetched('[' + expand.join(', ') + ']');
}
return query.then(utils.omitRows(omissions()));
});
},
/**
* @param {Access} access
* @param {Integer} [id_requested]
* @returns {[String]}
*/
getUserOmisionsByAccess: (access, id_requested) => {
let response = []; // Admin response
if (!access.token.hasScope('admin') && access.token.getUserId(0) !== id_requested) {
response = ['roles', 'is_deleted']; // Restricted response
}
return response;
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
* @param {String} data.type
* @param {String} data.secret
* @return {Promise}
*/
setPassword: (access, data) => {
return access.can('users:password', data.id)
.then(() => {
return internalUser.get(access, {id: data.id});
})
.then((user) => {
if (user.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id);
}
if (user.id === access.token.getUserId(0)) {
// they're setting their own password. Make sure their current password is correct
if (typeof data.current === 'undefined' || !data.current) {
throw new error.ValidationError('Current password was not supplied');
}
return internalToken.getTokenFromEmail({
identity: user.email,
secret: data.current
})
.then(() => {
return user;
});
}
return user;
})
.then((user) => {
// Get auth, patch if it exists
return authModel
.query()
.where('user_id', user.id)
.andWhere('type', data.type)
.first()
.then((existing_auth) => {
if (existing_auth) {
// patch
return authModel
.query()
.where('user_id', user.id)
.andWhere('type', data.type)
.patch({
type: data.type, // This is required for the model to encrypt on save
secret: data.secret
});
} else {
// insert
return authModel
.query()
.insert({
user_id: user.id,
type: data.type,
secret: data.secret,
meta: {}
});
}
})
.then(() => {
// Add to Audit Log
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'user',
object_id: user.id,
meta: {
name: user.name,
password_changed: true,
auth_type: data.type
}
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @return {Promise}
*/
setPermissions: (access, data) => {
return access.can('users:permissions', data.id)
.then(() => {
return internalUser.get(access, {id: data.id});
})
.then((user) => {
if (user.id !== data.id) {
// Sanity check that something crazy hasn't happened
throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id);
}
return user;
})
.then((user) => {
// Get perms row, patch if it exists
return userPermissionModel
.query()
.where('user_id', user.id)
.first()
.then((existing_auth) => {
if (existing_auth) {
// patch
return userPermissionModel
.query()
.where('user_id', user.id)
.patchAndFetchById(existing_auth.id, _.assign({user_id: user.id}, data));
} else {
// insert
return userPermissionModel
.query()
.insertAndFetch(_.assign({user_id: user.id}, data));
}
})
.then((permissions) => {
// Add to Audit Log
return internalAuditLog.add(access, {
action: 'updated',
object_type: 'user',
object_id: user.id,
meta: {
name: user.name,
permissions: permissions
}
});
});
})
.then(() => {
return true;
});
},
/**
* @param {Access} access
* @param {Object} data
* @param {Integer} data.id
*/
loginAs: (access, data) => {
return access.can('users:loginas', data.id)
.then(() => {
return internalUser.get(access, data);
})
.then((user) => {
return internalToken.getTokenFromUser(user);
});
}
};
module.exports = internalUser;
-19
View File
@@ -1,19 +0,0 @@
module.exports = {
development: {
client: 'mysql2',
migrations: {
tableName: 'migrations',
stub: 'lib/migrate_template.js',
directory: 'migrations'
}
},
production: {
client: 'mysql2',
migrations: {
tableName: 'migrations',
stub: 'lib/migrate_template.js',
directory: 'migrations'
}
}
};
-307
View File
@@ -1,307 +0,0 @@
/**
* Some Notes: This is a friggin complicated piece of code.
*
* "scope" in this file means "where did this token come from and what is using it", so 99% of the time
* the "scope" is going to be "user" because it would be a user token. This is not to be confused with
* the "role" which could be "user" or "admin". The scope in fact, could be "worker" or anything else.
*
*
*/
const _ = require('lodash');
const logger = require('../logger').access;
const Ajv = require('ajv/dist/2020');
const error = require('./error');
const userModel = require('../models/user');
const proxyHostModel = require('../models/proxy_host');
const TokenModel = require('../models/token');
const roleSchema = require('./access/roles.json');
const permsSchema = require('./access/permissions.json');
module.exports = function (token_string) {
let Token = new TokenModel();
let token_data = null;
let initialised = false;
let object_cache = {};
let allow_internal_access = false;
let user_roles = [];
let permissions = {};
/**
* Loads the Token object from the token string
*
* @returns {Promise}
*/
this.init = () => {
return new Promise((resolve, reject) => {
if (initialised) {
resolve();
} else if (!token_string) {
reject(new error.PermissionError('Permission Denied'));
} else {
resolve(Token.load(token_string)
.then((data) => {
token_data = data;
// At this point we need to load the user from the DB and make sure they:
// - exist (and not soft deleted)
// - still have the appropriate scopes for this token
// This is only required when the User ID is supplied or if the token scope has `user`
if (token_data.attrs.id || (typeof token_data.scope !== 'undefined' && _.indexOf(token_data.scope, 'user') !== -1)) {
// Has token user id or token user scope
return userModel
.query()
.where('id', token_data.attrs.id)
.andWhere('is_deleted', 0)
.andWhere('is_disabled', 0)
.allowGraph('[permissions]')
.withGraphFetched('[permissions]')
.first()
.then((user) => {
if (user) {
// make sure user has all scopes of the token
// The `user` role is not added against the user row, so we have to just add it here to get past this check.
user.roles.push('user');
let is_ok = true;
_.forEach(token_data.scope, (scope_item) => {
if (_.indexOf(user.roles, scope_item) === -1) {
is_ok = false;
}
});
if (!is_ok) {
throw new error.AuthError('Invalid token scope for User');
} else {
initialised = true;
user_roles = user.roles;
permissions = user.permissions;
}
} else {
throw new error.AuthError('User cannot be loaded for Token');
}
});
} else {
initialised = true;
}
}));
}
});
};
/**
* Fetches the object ids from the database, only once per object type, for this token.
* This only applies to USER token scopes, as all other tokens are not really bound
* by object scopes
*
* @param {String} object_type
* @returns {Promise}
*/
this.loadObjects = (object_type) => {
return new Promise((resolve, reject) => {
if (Token.hasScope('user')) {
if (typeof token_data.attrs.id === 'undefined' || !token_data.attrs.id) {
reject(new error.AuthError('User Token supplied without a User ID'));
} else {
let token_user_id = token_data.attrs.id ? token_data.attrs.id : 0;
let query;
if (typeof object_cache[object_type] === 'undefined') {
switch (object_type) {
// USERS - should only return yourself
case 'users':
resolve(token_user_id ? [token_user_id] : []);
break;
// Proxy Hosts
case 'proxy_hosts':
query = proxyHostModel
.query()
.select('id')
.andWhere('is_deleted', 0);
if (permissions.visibility === 'user') {
query.andWhere('owner_user_id', token_user_id);
}
resolve(query
.then((rows) => {
let result = [];
_.forEach(rows, (rule_row) => {
result.push(rule_row.id);
});
// enum should not have less than 1 item
if (!result.length) {
result.push(0);
}
return result;
})
);
break;
// DEFAULT: null
default:
resolve(null);
break;
}
} else {
resolve(object_cache[object_type]);
}
}
} else {
resolve(null);
}
})
.then((objects) => {
object_cache[object_type] = objects;
return objects;
});
};
/**
* Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema
*
* @param {String} permission_label
* @returns {Object}
*/
this.getObjectSchema = (permission_label) => {
let base_object_type = permission_label.split(':').shift();
let schema = {
$id: 'objects',
description: 'Actor Properties',
type: 'object',
additionalProperties: false,
properties: {
user_id: {
anyOf: [
{
type: 'number',
enum: [Token.get('attrs').id]
}
]
},
scope: {
type: 'string',
pattern: '^' + Token.get('scope') + '$'
}
}
};
return this.loadObjects(base_object_type)
.then((object_result) => {
if (typeof object_result === 'object' && object_result !== null) {
schema.properties[base_object_type] = {
type: 'number',
enum: object_result,
minimum: 1
};
} else {
schema.properties[base_object_type] = {
type: 'number',
minimum: 1
};
}
return schema;
});
};
return {
token: Token,
/**
*
* @param {Boolean} [allow_internal]
* @returns {Promise}
*/
load: (allow_internal) => {
return new Promise(function (resolve/*, reject*/) {
if (token_string) {
resolve(Token.load(token_string));
} else {
allow_internal_access = allow_internal;
resolve(allow_internal_access || null);
}
});
},
reloadObjects: this.loadObjects,
/**
*
* @param {String} permission
* @param {*} [data]
* @returns {Promise}
*/
can: (permission, data) => {
if (allow_internal_access === true) {
return Promise.resolve(true);
//return true;
} else {
return this.init()
.then(() => {
// Initialised, token decoded ok
return this.getObjectSchema(permission)
.then((objectSchema) => {
const data_schema = {
[permission]: {
data: data,
scope: Token.get('scope'),
roles: user_roles,
permission_visibility: permissions.visibility,
permission_proxy_hosts: permissions.proxy_hosts,
permission_redirection_hosts: permissions.redirection_hosts,
permission_dead_hosts: permissions.dead_hosts,
permission_streams: permissions.streams,
permission_access_lists: permissions.access_lists,
permission_certificates: permissions.certificates
}
};
let permissionSchema = {
$async: true,
$id: 'permissions',
type: 'object',
additionalProperties: false,
properties: {}
};
permissionSchema.properties[permission] = require('./access/' + permission.replace(/:/gim, '-') + '.json');
const ajv = new Ajv({
verbose: true,
allErrors: true,
breakOnError: true,
coerceTypes: true,
schemas: [
roleSchema,
permsSchema,
objectSchema,
permissionSchema
]
});
return ajv.validate('permissions', data_schema)
.then(() => {
return data_schema[permission];
});
});
})
.catch((err) => {
err.permission = permission;
err.permission_data = data;
logger.error(permission, data, err.message);
throw new error.PermissionError('Permission Denied', err);
});
}
}
};
};
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_access_lists", "roles"],
"properties": {
"permission_access_lists": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_access_lists", "roles"],
"properties": {
"permission_access_lists": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_access_lists", "roles"],
"properties": {
"permission_access_lists": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_access_lists", "roles"],
"properties": {
"permission_access_lists": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_access_lists", "roles"],
"properties": {
"permission_access_lists": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_certificates", "roles"],
"properties": {
"permission_certificates": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_certificates", "roles"],
"properties": {
"permission_certificates": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_certificates", "roles"],
"properties": {
"permission_certificates": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_certificates", "roles"],
"properties": {
"permission_certificates": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_certificates", "roles"],
"properties": {
"permission_certificates": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_dead_hosts", "roles"],
"properties": {
"permission_dead_hosts": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_dead_hosts", "roles"],
"properties": {
"permission_dead_hosts": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_dead_hosts", "roles"],
"properties": {
"permission_dead_hosts": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_dead_hosts", "roles"],
"properties": {
"permission_dead_hosts": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_dead_hosts", "roles"],
"properties": {
"permission_dead_hosts": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-13
View File
@@ -1,13 +0,0 @@
{
"$id": "perms",
"definitions": {
"view": {
"type": "string",
"pattern": "^(view|manage)$"
},
"manage": {
"type": "string",
"pattern": "^(manage)$"
}
}
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_proxy_hosts", "roles"],
"properties": {
"permission_proxy_hosts": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_proxy_hosts", "roles"],
"properties": {
"permission_proxy_hosts": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_proxy_hosts", "roles"],
"properties": {
"permission_proxy_hosts": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_proxy_hosts", "roles"],
"properties": {
"permission_proxy_hosts": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_proxy_hosts", "roles"],
"properties": {
"permission_proxy_hosts": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_redirection_hosts", "roles"],
"properties": {
"permission_redirection_hosts": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_redirection_hosts", "roles"],
"properties": {
"permission_redirection_hosts": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_redirection_hosts", "roles"],
"properties": {
"permission_redirection_hosts": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_redirection_hosts", "roles"],
"properties": {
"permission_redirection_hosts": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_redirection_hosts", "roles"],
"properties": {
"permission_redirection_hosts": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/user"
}
]
}
-38
View File
@@ -1,38 +0,0 @@
{
"$id": "roles",
"definitions": {
"admin": {
"type": "object",
"required": ["scope", "roles"],
"properties": {
"scope": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^user$"
}
},
"roles": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^admin$"
}
}
}
},
"user": {
"type": "object",
"required": ["scope"],
"properties": {
"scope": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^user$"
}
}
}
}
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_streams", "roles"],
"properties": {
"permission_streams": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_streams", "roles"],
"properties": {
"permission_streams": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_streams", "roles"],
"properties": {
"permission_streams": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_streams", "roles"],
"properties": {
"permission_streams": {
"$ref": "perms#/definitions/view"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["permission_streams", "roles"],
"properties": {
"permission_streams": {
"$ref": "perms#/definitions/manage"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"enum": ["user"]
}
}
}
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["data", "scope"],
"properties": {
"data": {
"$ref": "objects#/properties/users"
},
"scope": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^user$"
}
}
}
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["data", "scope"],
"properties": {
"data": {
"$ref": "objects#/properties/users"
},
"scope": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^user$"
}
}
}
}
]
}
@@ -1,7 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
-23
View File
@@ -1,23 +0,0 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
},
{
"type": "object",
"required": ["data", "scope"],
"properties": {
"data": {
"$ref": "objects#/properties/users"
},
"scope": {
"type": "array",
"contains": {
"type": "string",
"pattern": "^user$"
}
}
}
}
]
}
-85
View File
@@ -1,85 +0,0 @@
const dnsPlugins = require('../global/certbot-dns-plugins.json');
const utils = require('./utils');
const error = require('./error');
const logger = require('../logger').certbot;
const batchflow = require('batchflow');
const CERTBOT_VERSION_REPLACEMENT = '$(certbot --version | grep -Eo \'[0-9](\\.[0-9]+)+\')';
const certbot = {
/**
* @param {array} pluginKeys
*/
installPlugins: async (pluginKeys) => {
let hasErrors = false;
return new Promise((resolve, reject) => {
if (pluginKeys.length === 0) {
resolve();
return;
}
batchflow(pluginKeys).sequential()
.each((_i, pluginKey, next) => {
certbot.installPlugin(pluginKey)
.then(() => {
next();
})
.catch((err) => {
hasErrors = true;
next(err);
});
})
.error((err) => {
logger.error(err.message);
})
.end(() => {
if (hasErrors) {
reject(new error.CommandError('Some plugins failed to install. Please check the logs above', 1));
} else {
resolve();
}
});
});
},
/**
* Installs a cerbot plugin given the key for the object from
* ../global/certbot-dns-plugins.json
*
* @param {string} pluginKey
* @returns {Object}
*/
installPlugin: async (pluginKey) => {
if (typeof dnsPlugins[pluginKey] === 'undefined') {
// throw Error(`Certbot plugin ${pluginKey} not found`);
throw new error.ItemNotFoundError(pluginKey);
}
const plugin = dnsPlugins[pluginKey];
logger.start(`Installing ${pluginKey}...`);
plugin.version = plugin.version.replace(/{{certbot-version}}/g, CERTBOT_VERSION_REPLACEMENT);
plugin.dependencies = plugin.dependencies.replace(/{{certbot-version}}/g, CERTBOT_VERSION_REPLACEMENT);
// SETUPTOOLS_USE_DISTUTILS is required for certbot plugins to install correctly
// in new versions of Python
let env = Object.assign({}, process.env, {SETUPTOOLS_USE_DISTUTILS: 'stdlib'});
if (typeof plugin.env === 'object') {
env = Object.assign(env, plugin.env);
}
const cmd = `. /opt/certbot/bin/activate && pip install --no-cache-dir ${plugin.dependencies} ${plugin.package_name}${plugin.version} && deactivate`;
return utils.exec(cmd, {env})
.then((result) => {
logger.complete(`Installed ${pluginKey}`);
return result;
})
.catch((err) => {
throw err;
});
},
};
module.exports = certbot;

Some files were not shown because too many files have changed in this diff Show More