first rewrite commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"],
|
||||
"rules": {
|
||||
"@next/next/no-img-element": "off"
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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 provider’s 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.
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
config/development.json
|
||||
data/*
|
||||
yarn-error.log
|
||||
tmp
|
||||
certbot.log
|
||||
node_modules
|
||||
core.*
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"printWidth": 320,
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": true,
|
||||
"trailingComma": "all",
|
||||
"proseWrap": "always"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
These files are use in development and are not deployed as part of the final product.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"database": {
|
||||
"engine": "mysql2",
|
||||
"host": "db",
|
||||
"name": "npm",
|
||||
"user": "npm",
|
||||
"password": "npm",
|
||||
"port": 3306
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/user"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,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
Reference in New Issue
Block a user