initial commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
**/node_modules
|
||||
**/build
|
||||
**/*.log
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/dns-cache.json
|
||||
**/settings.json
|
||||
**/users.json
|
||||
**/audit-log.json
|
||||
**/secrets.json
|
||||
**/ipam.json
|
||||
**/.last-secret-check
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment & credentials — never commit these
|
||||
backend/.env
|
||||
**/.env.local
|
||||
|
||||
# Runtime data files
|
||||
backend/dns-cache.json
|
||||
backend/settings.json
|
||||
backend/users.json
|
||||
backend/audit-log.json
|
||||
backend/secrets.json
|
||||
backend/ipam.json
|
||||
backend/.last-secret-check
|
||||
|
||||
# React build output
|
||||
frontend/build/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,98 @@
|
||||
# API Access Requirements
|
||||
|
||||
This document describes the credentials and permissions needed for each DNS provider.
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare
|
||||
|
||||
**Credential:** API Token (`CLOUDFLARE_API_TOKEN`)
|
||||
|
||||
Create a token at **dash.cloudflare.com → My Profile → API Tokens → Create Token**.
|
||||
|
||||
Use the "Create Custom Token" option and grant the following permissions:
|
||||
|
||||
| Resource | Permission |
|
||||
|----------|------------|
|
||||
| Zone — Zone | Read |
|
||||
| Zone — DNS | Edit |
|
||||
|
||||
Scope the token to specific zones or all zones depending on your preference. Do **not** use a Global API Key — a scoped token is safer.
|
||||
|
||||
---
|
||||
|
||||
## Loopia
|
||||
|
||||
**Credentials:** API username (`LOOPIA_USER`) and password (`LOOPIA_PASSWORD`)
|
||||
|
||||
Create a dedicated API user at **customerzone.loopia.se → My Account → API Users**.
|
||||
|
||||
The API username will be in the format `youruser@loopiaapi`.
|
||||
|
||||
Grant access to the domains you want to manage and enable the following API method group:
|
||||
|
||||
| Method group | Required for |
|
||||
|--------------|-------------|
|
||||
| DNS zone (Zone records) | List, add, update and delete DNS records |
|
||||
|
||||
No other method groups (billing, domain registration, email, etc.) are needed.
|
||||
|
||||
---
|
||||
|
||||
## Pi-hole
|
||||
|
||||
**Credentials:** Pi-hole URL (`PIHOLE_URL`) and web password (`PIHOLE_PASSWORD`)
|
||||
|
||||
The application uses the **Pi-hole v6 REST API**. No separate API user is needed — the same password you use to log in to the Pi-hole web interface is used here.
|
||||
|
||||
Set `PIHOLE_URL` to the base URL of your Pi-hole instance, for example `http://192.168.1.x`.
|
||||
|
||||
> **Note:** Pi-hole local DNS only supports **A**, **AAAA**, and **CNAME** record types. TTL and priority are not configurable through the Pi-hole API.
|
||||
|
||||
---
|
||||
|
||||
## Azure DNS
|
||||
|
||||
**Credentials:** Service Principal (`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SUBSCRIPTION_ID`)
|
||||
|
||||
Create a Service Principal via the Azure Portal or Azure CLI:
|
||||
|
||||
```bash
|
||||
az ad sp create-for-rbac --name "sloth-manager" --role "DNS Zone Contributor" --scopes /subscriptions/<subscription-id>
|
||||
```
|
||||
|
||||
This outputs the `appId` (client ID), `password` (client secret), and `tenant`. Assign at minimum the **DNS Zone Contributor** role on the subscription or on the specific resource groups containing your DNS zones.
|
||||
|
||||
| Credential | Description |
|
||||
|------------|-------------|
|
||||
| `AZURE_TENANT_ID` | Azure AD tenant ID (shown in Azure Portal → Azure Active Directory) |
|
||||
| `AZURE_CLIENT_ID` | Application (client) ID of the service principal |
|
||||
| `AZURE_CLIENT_SECRET` | Client secret value |
|
||||
| `AZURE_SUBSCRIPTION_ID` | Subscription ID (shown in Azure Portal → Subscriptions) |
|
||||
|
||||
---
|
||||
|
||||
## cPanel
|
||||
|
||||
**Credentials:** cPanel URL (`CPANEL_URL`), username (`CPANEL_USERNAME`), API token (`CPANEL_API_TOKEN`)
|
||||
|
||||
Create an API token inside cPanel:
|
||||
|
||||
1. Log in to your cPanel account
|
||||
2. Go to **Security → Manage API Tokens**
|
||||
3. Click **Create** and give it a name, e.g. `sloth-manager`
|
||||
4. Copy the generated token
|
||||
|
||||
Set `CPANEL_URL` to your cPanel instance URL including port, e.g. `https://hostname:2083`.
|
||||
|
||||
The cPanel account must own the domains you want to manage. This adapter uses the **cPanel UAPI** for reading and the **ZoneEdit API 2** module for writing — no WHM or root access is needed.
|
||||
|
||||
> **Self-signed certificate:** If your cPanel server uses a self-signed SSL certificate, set `CPANEL_INSECURE=true` in `.env` to disable certificate verification.
|
||||
|
||||
> **Imunify360:** Some hosting providers run Imunify360 WAF which blocks automated API requests. If you receive an "Access denied by Imunify360 bot-protection" error, contact your hosting provider and ask them to whitelist the IP address of the machine running the DNS Manager backend.
|
||||
|
||||
---
|
||||
|
||||
## Adding credentials
|
||||
|
||||
All credentials are configured in `backend/.env`. Copy `backend/.env.example` to `backend/.env` and fill in the values for the providers you want to use. Providers with missing or incomplete credentials will not appear in the application.
|
||||
@@ -0,0 +1,95 @@
|
||||
# DNS Record Cache
|
||||
|
||||
Sloth Manager stores a local copy of all synced DNS records in `backend/dns-cache.json`. This document explains how the cache works, why it exists, and how to manage it.
|
||||
|
||||
---
|
||||
|
||||
## Why a local cache?
|
||||
|
||||
Some DNS providers (notably Loopia) enforce strict API rate limits. Reading records directly from the provider API on every page load would quickly exhaust those limits. Instead, Sloth Manager separates reads from writes:
|
||||
|
||||
- **Reads** always come from the local cache — instant, no API calls.
|
||||
- **Writes** (add, edit, delete) go directly to the provider API and immediately update the cache.
|
||||
- **Sync** is the only action that calls the provider API to fetch fresh records.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
### Syncing
|
||||
|
||||
Pressing **⟳ Sync** on a zone sends a single request to the provider API, replaces all cached records for that zone, and records the sync timestamp. The cache is also updated from the **Provider Overview** page, where each zone has its own Sync button.
|
||||
|
||||
### Writes
|
||||
|
||||
When you add, edit, or delete a record through the app, the change is applied to the provider immediately. The local cache is updated in the same request so the table stays accurate without needing a re-sync.
|
||||
|
||||
### Zone names
|
||||
|
||||
The first time a zone is synced, its human-readable name (e.g. `example.com`) is stored alongside the records. This name is used in Gotify notifications. If a notification shows a raw zone ID instead of a domain name, syncing that zone will fix it.
|
||||
|
||||
---
|
||||
|
||||
## Cache file structure
|
||||
|
||||
`dns-cache.json` is a plain JSON file stored in the `backend/` folder:
|
||||
|
||||
```json
|
||||
{
|
||||
"cloudflare": {
|
||||
"abc123zoneId": {
|
||||
"zone_name": "example.com",
|
||||
"synced_at": "2026-06-01T12:00:00.000Z",
|
||||
"records": [
|
||||
{
|
||||
"id": "rec123",
|
||||
"type": "A",
|
||||
"name": "sub.example.com",
|
||||
"content": "1.2.3.4",
|
||||
"ttl": 3600,
|
||||
"priority": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"loopia": { ... },
|
||||
"pihole": { ... },
|
||||
"azure": { ... },
|
||||
"cpanel": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Each provider is a top-level key. Within each provider, zones are keyed by their provider-specific zone ID. Each zone stores the friendly name, the last sync timestamp, and the flat list of records.
|
||||
|
||||
---
|
||||
|
||||
## Managing the cache
|
||||
|
||||
### Clearing via the app
|
||||
|
||||
Go to **⚙️ Settings → Cache** and press **Clear Cache**. This empties the entire cache file. All zones will show "Never synced" and will need to be re-synced before records are visible.
|
||||
|
||||
### Clearing manually
|
||||
|
||||
Delete or empty `backend/dns-cache.json`. The backend does not need to be restarted — the file is read fresh on every request.
|
||||
|
||||
### Clearing a single zone
|
||||
|
||||
The cache does not currently support clearing individual zones. The cleanest workaround is to sync the zone immediately after clearing the full cache, which repopulates just that zone.
|
||||
|
||||
### Custom file location
|
||||
|
||||
Set `DB_PATH` in `.env` to store the cache somewhere else:
|
||||
|
||||
```env
|
||||
DB_PATH=/data/dns-cache.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Important notes
|
||||
|
||||
- The cache reflects the state of records **at the time of the last sync**. Changes made directly in the provider's dashboard (outside of Sloth Manager) will not appear until you sync.
|
||||
- Deleting the cache file does not affect any records at the provider — it only removes the local copy.
|
||||
- The cache is not encrypted. Do not store it in a publicly accessible location.
|
||||
- Record IDs are provider-specific. Cloudflare uses UUID strings, Loopia uses `subdomain::lineNumber` composites, Pi-hole uses `dns::domain::ip` composites, and so on. These IDs are internal to Sloth Manager and should not be relied upon externally.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Environment Configuration
|
||||
|
||||
All settings are configured in `backend/.env`. Copy `backend/.env.example` to `backend/.env` and fill in the values for the providers you want to use. The backend must be restarted after any changes to `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `CLOUDFLARE_API_TOKEN` | Yes | API token with Zone:Read and DNS:Edit permissions |
|
||||
|
||||
Create a token at **dash.cloudflare.com → My Profile → API Tokens → Create Token**. See `API-ACCESS.md` for the required permissions.
|
||||
|
||||
---
|
||||
|
||||
## Loopia
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `LOOPIA_USER` | Yes | API username in the format `youruser@loopiaapi` |
|
||||
| `LOOPIA_PASSWORD` | Yes | API user password |
|
||||
|
||||
Create an API user at **customerzone.loopia.se → My Account → API Users**. See `API-ACCESS.md` for the required method groups.
|
||||
|
||||
---
|
||||
|
||||
## Pi-hole
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `PIHOLE_URL` | Yes | Base URL of the Pi-hole instance, e.g. `http://192.168.1.x` |
|
||||
| `PIHOLE_PASSWORD` | Yes | Pi-hole web interface password |
|
||||
|
||||
Requires Pi-hole v6. Only A, AAAA, and CNAME records are supported. TTL is not configurable via the Pi-hole API.
|
||||
|
||||
---
|
||||
|
||||
## Azure DNS
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `AZURE_TENANT_ID` | Yes | Azure AD tenant ID |
|
||||
| `AZURE_CLIENT_ID` | Yes | Service principal application (client) ID |
|
||||
| `AZURE_CLIENT_SECRET` | Yes | Service principal client secret |
|
||||
| `AZURE_SUBSCRIPTION_ID` | Yes | Azure subscription ID containing the DNS zones |
|
||||
|
||||
The service principal requires the **DNS Zone Contributor** role on the subscription or resource group. See `API-ACCESS.md` for setup instructions.
|
||||
|
||||
---
|
||||
|
||||
## cPanel
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `CPANEL_URL` | Yes | cPanel URL including port, e.g. `https://hostname:2083` |
|
||||
| `CPANEL_USERNAME` | Yes | cPanel account username |
|
||||
| `CPANEL_API_TOKEN` | Yes | API token created in cPanel → Security → Manage API Tokens |
|
||||
| `CPANEL_INSECURE` | No | Set to `true` to disable SSL certificate verification. Use when cPanel uses a self-signed certificate. Defaults to `false`. |
|
||||
|
||||
The cPanel account must own the domains you want to manage. Uses the cPanel UAPI and API 2 (ZoneEdit module). See `API-ACCESS.md` for setup instructions.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `JWT_SECRET` | Yes | A long random string used to sign login tokens. Generate one with: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |
|
||||
| `JWT_EXPIRES_IN` | No | How long login sessions last. Defaults to `24h`. Accepts values like `12h`, `7d`. |
|
||||
|
||||
---
|
||||
|
||||
## General
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `DISABLED_PROVIDERS` | No | Comma-separated list of provider IDs to hide from the app without removing credentials. Valid values: `cloudflare`, `loopia`, `pihole`, `azure`, `cpanel`. Example: `DISABLED_PROVIDERS=loopia,cpanel` |
|
||||
| `PORT` | No | Port the backend listens on. Defaults to `3001`. |
|
||||
| `DB_PATH` | No | Path to the DNS record cache file. Defaults to `backend/dns-cache.json`. |
|
||||
| `SETTINGS_PATH` | No | Path to the settings file. Defaults to `backend/settings.json`. |
|
||||
| `USERS_PATH` | No | Path to the users file. Defaults to `backend/users.json`. |
|
||||
| `AUDIT_PATH` | No | Path to the audit log file. Defaults to `backend/audit-log.json`. |
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
```env
|
||||
# Cloudflare
|
||||
CLOUDFLARE_API_TOKEN=your_token_here
|
||||
|
||||
# Loopia
|
||||
LOOPIA_USER=youruser@loopiaapi
|
||||
LOOPIA_PASSWORD=yourpassword
|
||||
|
||||
# Pi-hole (v6)
|
||||
PIHOLE_URL=http://192.168.1.10
|
||||
PIHOLE_PASSWORD=yourpassword
|
||||
|
||||
# Azure DNS
|
||||
AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
AZURE_CLIENT_SECRET=your_secret
|
||||
AZURE_SUBSCRIPTION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
|
||||
# cPanel
|
||||
CPANEL_URL=https://hostname:2083
|
||||
CPANEL_USERNAME=myuser
|
||||
CPANEL_API_TOKEN=your_token
|
||||
CPANEL_INSECURE=false
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=your-long-random-secret-here
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
# Disable specific providers
|
||||
DISABLED_PROVIDERS=
|
||||
|
||||
PORT=3001
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# IP Addresses (IPAM)
|
||||
|
||||
The IP Address tool provides a central place to store and manage the public IP addresses of your servers, VPSs, and services across different vendors. It also cross-references each IP against your locally cached DNS records to show which domains currently point to it.
|
||||
|
||||
---
|
||||
|
||||
## Accessing the tool
|
||||
|
||||
Navigate to **🖥️ IP Addresses** in the sidebar under the **Tools** section.
|
||||
|
||||
---
|
||||
|
||||
## IP address fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| IP Address | Yes | An IPv4 or IPv6 address. Cannot be changed after creation — delete and re-add if the address changes. |
|
||||
| Label | No | A friendly name, e.g. `Web Server 1` or `VPN Gateway` |
|
||||
| Vendor / Provider | No | The hosting provider, e.g. `Hetzner`, `DigitalOcean`, `AWS` |
|
||||
| Location / Region | No | The server location, e.g. `Frankfurt`, `US-East` |
|
||||
| Notes | No | Any additional free-text notes (shown as an ℹ icon in the table) |
|
||||
|
||||
Both **IPv4** (shown in blue) and **IPv6** (shown in purple) addresses are supported.
|
||||
|
||||
---
|
||||
|
||||
## DNS cross-reference
|
||||
|
||||
The **DNS Records** column automatically scans the locally cached DNS records and shows any **A** or **AAAA** records whose value matches the stored IP address. Each match displays the provider badge and the full domain name.
|
||||
|
||||
This makes it easy to see at a glance which domains are pointing to a given server, and to spot IPs that are not referenced by any DNS records.
|
||||
|
||||
> **Note:** The cross-reference is based on the local DNS cache. Sync the relevant zones to ensure the data is up to date.
|
||||
|
||||
---
|
||||
|
||||
## Filtering
|
||||
|
||||
Use the filter bar to search across IP address, label, vendor, and location fields simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Export
|
||||
|
||||
Press **⬇ Export CSV** to download the current (filtered) list as a CSV file. The export includes the IP address, label, vendor, location, notes, and the matching DNS records as a semicolon-separated string.
|
||||
|
||||
---
|
||||
|
||||
## Audit log
|
||||
|
||||
All IP address changes (add, update, delete) are recorded in **📋 Audit Log** under the category **IP Address**.
|
||||
|
||||
---
|
||||
|
||||
## Data storage
|
||||
|
||||
IP addresses are stored in `backend/ipam.json`. This file is created automatically on first use. The path can be overridden with the `IPAM_PATH` environment variable — see `ENVIRONMENT.md` for details.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Notifications
|
||||
|
||||
Sloth Manager can send push notifications to a [Gotify](https://gotify.net/) instance whenever a DNS record is added, updated, or deleted.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
1. Open the app and navigate to **⚙️ Settings → Notifications** in the sidebar
|
||||
2. Fill in your Gotify details:
|
||||
- **Gotify URL** — base URL of your Gotify instance, e.g. `http://192.168.1.x` or `https://gotify.example.com`
|
||||
- **App Token** — token from a Gotify application (see below)
|
||||
- **Priority** — notification priority from 1 (low) to 10 (high), default is 5
|
||||
3. Toggle **Enable notifications** on
|
||||
4. Press **Send Test** to verify the connection before saving
|
||||
5. Press **Save Settings**
|
||||
|
||||
Settings are stored in `backend/settings.json` and persist across restarts.
|
||||
|
||||
---
|
||||
|
||||
## Creating a Gotify app token
|
||||
|
||||
1. Log in to your Gotify web interface
|
||||
2. Go to **Apps** and click **Create application**
|
||||
3. Give it a name, e.g. `Sloth Manager`
|
||||
4. Copy the generated token and paste it into the App Token field in Settings
|
||||
|
||||
---
|
||||
|
||||
## Notification events
|
||||
|
||||
A notification is sent for every record change made through the app:
|
||||
|
||||
| Event | Title | Example message |
|
||||
|-------|-------|-----------------|
|
||||
| Record added | `DNS Record Added` | `[cloudflare] example.com` / `+ A sub.example.com → 1.2.3.4` |
|
||||
| Record updated | `DNS Record Updated` | `[loopia] example.com` / `✎ A sub.example.com` / `1.2.3.4 → 5.6.7.8` |
|
||||
| Record deleted | `DNS Record Deleted` | `[pihole] example.com` / `− CNAME sub.example.com target.example.com` |
|
||||
|
||||
Notifications are **not** sent when syncing records from a provider — only when changes are made through the app.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Send Test returns an error**
|
||||
- Verify the Gotify URL is reachable from the machine running the backend
|
||||
- Check that the app token is correct and belongs to an active application
|
||||
- Make sure there is no trailing slash in the Gotify URL
|
||||
|
||||
**Notifications stopped arriving**
|
||||
- Check that **Enable notifications** is still toggled on in Settings → Notifications
|
||||
- Verify the Gotify server is running and the app token has not been deleted
|
||||
- Check the backend terminal output — notification errors are logged there without interrupting normal operation
|
||||
|
||||
**Zone shows as an ID instead of a domain name**
|
||||
- Press **⟳ Sync** on the affected zone — this stores the domain name in the local cache, which is used to resolve the friendly name in notifications
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide covers deploying Sloth Manager to a production server using Docker.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker 24+ and Docker Compose v2
|
||||
- A Linux server (Ubuntu 22.04+ recommended)
|
||||
- A domain name (optional but recommended for HTTPS)
|
||||
|
||||
---
|
||||
|
||||
## Quick start with Docker
|
||||
|
||||
### 1. Prepare the environment file
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and fill in all credentials. Pay special attention to:
|
||||
|
||||
```env
|
||||
# Generate a strong secret — required for login tokens
|
||||
JWT_SECRET=your-long-random-secret-here
|
||||
|
||||
# Set a longer expiry for production if desired
|
||||
JWT_EXPIRES_IN=24h
|
||||
```
|
||||
|
||||
Generate a strong `JWT_SECRET`:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
Or with OpenSSL:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### 2. Build and start
|
||||
|
||||
From the project root (where `docker-compose.yml` is):
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Sloth Manager will be available at `http://your-server-ip`.
|
||||
|
||||
### 3. First login
|
||||
|
||||
On first start the backend creates a default admin account. Check the logs:
|
||||
|
||||
```bash
|
||||
docker compose logs backend | grep "admin"
|
||||
```
|
||||
|
||||
Log in at `http://your-server-ip` with `admin` / `admin`, then immediately change the password in **👤 My Profile**.
|
||||
|
||||
---
|
||||
|
||||
## Data persistence
|
||||
|
||||
All application data is stored in the `sloth-data` Docker volume, which maps to `/data` inside the backend container:
|
||||
|
||||
| File | Contents |
|
||||
|------|---------|
|
||||
| `dns-cache.json` | Cached DNS records |
|
||||
| `settings.json` | App settings (Gotify, colours) |
|
||||
| `users.json` | User accounts |
|
||||
| `audit-log.json` | Audit history |
|
||||
| `secrets.json` | Secrets tracker data |
|
||||
| `ipam.json` | IP address data |
|
||||
|
||||
**Back up this volume regularly.** To export:
|
||||
|
||||
```bash
|
||||
docker run --rm -v sloth-data:/data -v $(pwd):/backup alpine \
|
||||
tar czf /backup/sloth-backup-$(date +%Y%m%d).tar.gz -C /data .
|
||||
```
|
||||
|
||||
To restore:
|
||||
|
||||
```bash
|
||||
docker run --rm -v sloth-data:/data -v $(pwd):/backup alpine \
|
||||
tar xzf /backup/sloth-backup-YYYYMMDD.tar.gz -C /data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTPS with a reverse proxy (recommended)
|
||||
|
||||
Running behind a reverse proxy with HTTPS is strongly recommended in production. Two common options:
|
||||
|
||||
### Option A — Nginx on the host with Let's Encrypt (Certbot)
|
||||
|
||||
1. Install nginx and certbot on the host
|
||||
2. Change the frontend port in `docker-compose.yml` to avoid conflict:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "127.0.0.1:8080:80" # bind only to localhost
|
||||
```
|
||||
|
||||
3. Create an nginx site config:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name sloth.yourdomain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/sloth.yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sloth.yourdomain.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name sloth.yourdomain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
4. Issue a certificate:
|
||||
|
||||
```bash
|
||||
certbot --nginx -d sloth.yourdomain.com
|
||||
```
|
||||
|
||||
### Option B — Traefik (Docker-native)
|
||||
|
||||
Add Traefik labels to the frontend service in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.sloth.rule=Host(`sloth.yourdomain.com`)"
|
||||
- "traefik.http.routers.sloth.entrypoints=websecure"
|
||||
- "traefik.http.routers.sloth.tls.certresolver=letsencrypt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Updating Sloth Manager
|
||||
|
||||
Pull the latest code and rebuild:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Data in the `sloth-data` volume is untouched during updates.
|
||||
|
||||
---
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
# View live logs
|
||||
docker compose logs -f
|
||||
|
||||
# View backend logs only
|
||||
docker compose logs -f backend
|
||||
|
||||
# Restart without rebuilding
|
||||
docker compose restart
|
||||
|
||||
# Stop everything
|
||||
docker compose down
|
||||
|
||||
# Stop and remove volumes (WARNING: deletes all data)
|
||||
docker compose down -v
|
||||
|
||||
# Open a shell in the backend container
|
||||
docker compose exec backend sh
|
||||
|
||||
# Check health status
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security checklist
|
||||
|
||||
Before going live, verify:
|
||||
|
||||
- [ ] `JWT_SECRET` is a randomly generated string of at least 32 characters
|
||||
- [ ] The default `admin` password has been changed
|
||||
- [ ] HTTPS is configured (either via reverse proxy or directly)
|
||||
- [ ] The server firewall allows only ports 80 and 443 (not 3001 directly)
|
||||
- [ ] The `.env` file is not committed to version control (check `.gitignore`)
|
||||
- [ ] Docker and the host OS are up to date
|
||||
- [ ] Automatic backups of the `sloth-data` volume are in place
|
||||
|
||||
---
|
||||
|
||||
## Development vs production
|
||||
|
||||
| | Development | Production |
|
||||
|---|---|---|
|
||||
| Start command | `npm run dev` (nodemon) | `docker compose up -d` |
|
||||
| Frontend | React dev server (port 3000) | nginx serving built files (port 80) |
|
||||
| API | Direct to port 3001 | Proxied through nginx |
|
||||
| Data files | Project folder | Docker volume `/data` |
|
||||
| HTTPS | Not needed | Strongly recommended |
|
||||
@@ -0,0 +1,172 @@
|
||||
# Sloth Manager
|
||||
|
||||
A self-hosted web app to manage DNS records across Cloudflare, Loopia, Pi-hole, Azure DNS, and cPanel from a single interface.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your credentials and settings. See `ENVIRONMENT.md` for a full reference of every variable.
|
||||
|
||||
Start the backend:
|
||||
|
||||
```bash
|
||||
npm run dev # development (auto-restarts on saved file changes)
|
||||
npm start # production
|
||||
```
|
||||
|
||||
> **Note:** `npm run dev` uses nodemon and will automatically restart when you edit existing files. However, if you add a **new file** to the project while nodemon is running, you need to stop (`Ctrl+C`) and restart manually — nodemon does not detect brand new files being required for the first time.
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm start # dev server on http://localhost:3000
|
||||
```
|
||||
|
||||
For production, build the static files and serve them via nginx or similar:
|
||||
|
||||
```bash
|
||||
npm run build # outputs to frontend/build/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## First run
|
||||
|
||||
On first start the backend automatically creates a default admin account and prints a warning in the terminal:
|
||||
|
||||
```
|
||||
⚠ No users found — created default admin account.
|
||||
⚠ Username: admin Password: admin
|
||||
⚠ Change this password immediately in Settings → Users.
|
||||
```
|
||||
|
||||
Log in with `admin` / `admin`, then go to **👤 My Profile** to change the password.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open `http://localhost:3000` and sign in
|
||||
2. The **🏠 Overview** page shows statistics based on your locally cached records
|
||||
3. The **🌍 All Domains** page shows every zone across all providers in one table — duplicate domains across providers are highlighted
|
||||
4. Select a provider from the sidebar, then select a zone to see its domains
|
||||
5. Press **⟳ Sync** to fetch the latest records from the provider — this is the only action that calls the provider API for reads
|
||||
6. Add, edit, or delete records directly from the table — changes apply to the provider immediately and update the local cache
|
||||
7. All record changes are logged in **📋 Audit Log**
|
||||
8. Configure notifications, users, provider tag colours, and cache under **⚙️ Settings**
|
||||
|
||||
---
|
||||
|
||||
## Data files
|
||||
|
||||
The backend stores these files at runtime:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/dns-cache.json` | Local cache of synced DNS records |
|
||||
| `backend/settings.json` | App settings (Gotify, provider colours) |
|
||||
| `backend/users.json` | User accounts (hashed passwords) |
|
||||
| `backend/audit-log.json` | Record change history (last 500 entries) |
|
||||
|
||||
All files are created automatically on first use.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| File | Contents |
|
||||
|------|---------|
|
||||
| `ENVIRONMENT.md` | Every `.env` variable explained |
|
||||
| `API-ACCESS.md` | API credentials and permissions for each provider |
|
||||
| `NOTIFICATIONS.md` | Gotify notification setup and troubleshooting |
|
||||
|
||||
---
|
||||
|
||||
## Adding more providers
|
||||
|
||||
1. Create `backend/src/adapters/yourprovider.js` exporting:
|
||||
- `listZones()` → `[{ id, name }]`
|
||||
- `listRecords(zoneId)` → `[{ id, type, name, content, ttl, priority }]`
|
||||
- `addRecord(zoneId, record)` → `{ id, ... }`
|
||||
- `updateRecord(zoneId, recordId, record)` → `{ id, ... }`
|
||||
- `deleteRecord(zoneId, recordId)`
|
||||
|
||||
2. Register it in `backend/src/routes/zones.js` and `records.js`
|
||||
|
||||
3. Add its env vars to `.env`
|
||||
|
||||
4. **Restart the backend** after making these changes
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
dns-manager/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── index.js # Express server entry point
|
||||
│ │ ├── db.js # JSON file cache
|
||||
│ │ ├── settings.js # Settings persistence
|
||||
│ │ ├── notify.js # Gotify notification helper
|
||||
│ │ ├── audit.js # Audit log
|
||||
│ │ ├── auth.js # JWT helpers & middleware
|
||||
│ │ ├── users.js # User management
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── zones.js
|
||||
│ │ │ ├── records.js
|
||||
│ │ │ ├── settings.js
|
||||
│ │ │ ├── auth.js
|
||||
│ │ │ └── users.js
|
||||
│ │ └── adapters/
|
||||
│ │ ├── cloudflare.js
|
||||
│ │ ├── loopia.js
|
||||
│ │ ├── pihole.js
|
||||
│ │ ├── azure.js
|
||||
│ │ └── cpanel.js
|
||||
│ ├── dns-cache.json # created on first sync
|
||||
│ ├── settings.json # created on first save
|
||||
│ ├── users.json # created on first start
|
||||
│ ├── audit-log.json # created on first change
|
||||
│ ├── .env.example
|
||||
│ └── package.json
|
||||
└── frontend/
|
||||
├── src/
|
||||
│ ├── App.js
|
||||
│ ├── App.css
|
||||
│ ├── index.js
|
||||
│ ├── api/dns.js
|
||||
│ ├── context/
|
||||
│ │ ├── ProviderColors.js
|
||||
│ │ └── Theme.js
|
||||
│ ├── utils/
|
||||
│ │ └── exportCsv.js
|
||||
│ └── components/
|
||||
│ ├── LoginPage.js
|
||||
│ ├── Dashboard.js
|
||||
│ ├── DomainsPage.js
|
||||
│ ├── ProviderOverview.js
|
||||
│ ├── RecordsTable.js
|
||||
│ ├── AddRecordForm.js
|
||||
│ ├── AuditPage.js
|
||||
│ ├── ProfilePage.js
|
||||
│ ├── SettingsPage.js
|
||||
│ └── ConfirmDialog.js
|
||||
└── package.json
|
||||
```
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# Secrets
|
||||
|
||||
The Secrets tool tracks expiry dates for API tokens, SSL certificates, passwords, and any other time-sensitive credentials. It provides in-app status indicators and daily Gotify notifications when secrets are about to expire or have already expired.
|
||||
|
||||
---
|
||||
|
||||
## Accessing the tool
|
||||
|
||||
Navigate to **🔑 Secrets** in the sidebar under the **Tools** section.
|
||||
|
||||
---
|
||||
|
||||
## Secret fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| Name | Yes | A clear identifier, e.g. `Cloudflare API Token` |
|
||||
| Type | Yes | One of: API Token, SSL Certificate, Password, Generic |
|
||||
| Description | No | What the secret is used for |
|
||||
| Expiry Date | Yes | The date the secret expires |
|
||||
| Warn (days before) | Yes | How many days before expiry to start showing a warning. Defaults to 30. |
|
||||
| Notes | No | Any additional free-text notes |
|
||||
|
||||
---
|
||||
|
||||
## Status indicators
|
||||
|
||||
Each secret is assigned a status based on the current date and its warning threshold:
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| **OK** (green) | Expiry is further away than the warning threshold |
|
||||
| **Expiring** (amber) | Expiry is within the warning window |
|
||||
| **Expired** (red) | The expiry date has passed |
|
||||
|
||||
The Days Left column shows how many days remain, or how many days ago the secret expired (shown as `Xd ago`).
|
||||
|
||||
---
|
||||
|
||||
## Filtering
|
||||
|
||||
Use the filter bar to search by name or description. The status dropdown lets you view only expired, expiring, or OK secrets.
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
Sloth Manager checks for expiring and expired secrets once per day at **08:00** and sends a single Gotify notification listing all secrets that need attention. The check also runs once when the backend starts, but only if it has not already run today — so restarting the backend will not spam notifications.
|
||||
|
||||
Notifications require Gotify to be configured and enabled in **⚙️ Settings → Notifications**.
|
||||
|
||||
Example notification:
|
||||
|
||||
```
|
||||
🦥 Sloth Manager — Secrets Alert
|
||||
|
||||
2 secrets need attention:
|
||||
|
||||
✕ EXPIRED — Azure Client Secret
|
||||
⚠ 12d left — SSL Certificate (example.com)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit log
|
||||
|
||||
All secret changes (add, update, delete) are recorded in **📋 Audit Log** under the category **Secret**.
|
||||
|
||||
---
|
||||
|
||||
## Export
|
||||
|
||||
Press **⬇ Export CSV** to download the current (filtered) list of secrets as a CSV file. The export includes name, type, description, expiry date, warning days, status, days left, and notes.
|
||||
|
||||
---
|
||||
|
||||
## Data storage
|
||||
|
||||
Secrets are stored in `backend/secrets.json`. This file is created automatically on first use. The path can be overridden with the `SECRETS_PATH` environment variable — see `ENVIRONMENT.md` for details.
|
||||
|
||||
> **Note:** Secret values themselves (e.g. the actual token or password) are not stored — only metadata such as the name, type, and expiry date.
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
*.json
|
||||
!package.json
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -0,0 +1,32 @@
|
||||
# Cloudflare
|
||||
CLOUDFLARE_API_TOKEN=6wDdCPIi63p9Mbd1bDsaoITtkkd9MKSCcRhnRzDZ
|
||||
|
||||
# Loopia
|
||||
LOOPIA_USER=your_loopia_api_user@loopiaapi
|
||||
LOOPIA_PASSWORD=your_loopia_api_password
|
||||
|
||||
# Pi-hole (v6)
|
||||
PIHOLE_URL=http://192.168.1.x
|
||||
PIHOLE_PASSWORD=your_pihole_web_password
|
||||
|
||||
# Azure DNS
|
||||
AZURE_TENANT_ID=your_tenant_id
|
||||
AZURE_CLIENT_ID=your_client_id
|
||||
AZURE_CLIENT_SECRET=your_client_secret
|
||||
AZURE_SUBSCRIPTION_ID=your_subscription_id
|
||||
|
||||
# cPanel
|
||||
CPANEL_URL=https://hostname:2083
|
||||
CPANEL_USERNAME=your_cpanel_username
|
||||
CPANEL_API_TOKEN=your_api_token
|
||||
CPANEL_INSECURE=false # set to true if cPanel uses a self-signed certificate
|
||||
|
||||
# Comma-separated list of providers to disable (credentials are kept but provider is hidden)
|
||||
# Example: DISABLED_PROVIDERS=loopia,cpanel
|
||||
DISABLED_PROVIDERS=
|
||||
|
||||
# Auth — generate a strong random secret, e.g: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
JWT_SECRET=change-this-to-a-long-random-string
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
PORT=3001
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (layer cache)
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy source
|
||||
COPY src/ ./src/
|
||||
|
||||
# Data directory for persistent files (mounted as a volume)
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3001 \
|
||||
DB_PATH=/data/dns-cache.json \
|
||||
SETTINGS_PATH=/data/settings.json \
|
||||
USERS_PATH=/data/users.json \
|
||||
AUDIT_PATH=/data/audit-log.json \
|
||||
SECRETS_PATH=/data/secrets.json \
|
||||
IPAM_PATH=/data/ipam.json
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
Generated
+1542
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "dns-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"node-schedule": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"xml2js": "^0.6.2",
|
||||
"xmlbuilder2": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Azure DNS adapter — uses the Azure Resource Manager REST API.
|
||||
*
|
||||
* Requires env:
|
||||
* AZURE_TENANT_ID Azure AD tenant ID
|
||||
* AZURE_CLIENT_ID Service principal application (client) ID
|
||||
* AZURE_CLIENT_SECRET Service principal client secret
|
||||
* AZURE_SUBSCRIPTION_ID Azure subscription ID
|
||||
*
|
||||
* The service principal needs the "DNS Zone Contributor" role (or higher)
|
||||
* on the subscription or resource group containing your DNS zones.
|
||||
*
|
||||
* Zone IDs are encoded as "resourceGroup::zoneName" so we know which
|
||||
* resource group to target for record operations.
|
||||
*/
|
||||
|
||||
const ARM_BASE = 'https://management.azure.com';
|
||||
const API_VERSION = '2018-05-01';
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let tokenCache = null;
|
||||
|
||||
async function getToken() {
|
||||
if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) return tokenCache.token;
|
||||
|
||||
const { AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } = process.env;
|
||||
const url = `https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: AZURE_CLIENT_ID,
|
||||
client_secret: AZURE_CLIENT_SECRET,
|
||||
scope: 'https://management.azure.com/.default',
|
||||
});
|
||||
|
||||
const res = await fetch(url, { method: 'POST', body });
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.error) {
|
||||
throw new Error(`Azure auth failed: ${data.error_description ?? data.error ?? res.statusText}`);
|
||||
}
|
||||
|
||||
tokenCache = {
|
||||
token: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
return tokenCache.token;
|
||||
}
|
||||
|
||||
async function armFetch(path, options = {}) {
|
||||
const token = await getToken();
|
||||
const url = `${ARM_BASE}${path}${path.includes('?') ? '&' : '?'}api-version=${API_VERSION}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
const text = await res.text();
|
||||
if (!text || !text.trim()) return null;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Azure returned non-JSON response (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = data?.error?.message ?? `Azure API error ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Zone helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function encodeZoneId(resourceGroup, zoneName) {
|
||||
return `${resourceGroup}::${zoneName}`;
|
||||
}
|
||||
|
||||
function decodeZoneId(zoneId) {
|
||||
const idx = zoneId.indexOf('::');
|
||||
return { resourceGroup: zoneId.slice(0, idx), zoneName: zoneId.slice(idx + 2) };
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function listZones() {
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
const data = await armFetch(`/subscriptions/${sub}/providers/Microsoft.Network/dnsZones`);
|
||||
|
||||
return (data.value ?? []).map(z => {
|
||||
// Extract resource group from the zone's resource ID
|
||||
// e.g. /subscriptions/.../resourceGroups/myRG/providers/Microsoft.Network/dnsZones/example.com
|
||||
const rgMatch = z.id.match(/resourceGroups\/([^/]+)\//i);
|
||||
const rg = rgMatch ? rgMatch[1] : 'unknown';
|
||||
return {
|
||||
id: encodeZoneId(rg, z.name),
|
||||
name: z.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function listRecords(zoneId) {
|
||||
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
const path = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/recordSets`;
|
||||
const data = await armFetch(path);
|
||||
|
||||
const records = [];
|
||||
for (const rs of data.value ?? []) {
|
||||
const type = rs.type.split('/').pop(); // e.g. "Microsoft.Network/dnsZones/A" → "A"
|
||||
const name = rs.name === '@' ? zoneName : `${rs.name}.${zoneName}`;
|
||||
const ttl = rs.properties.TTL;
|
||||
|
||||
// Each record set can contain multiple records — flatten them
|
||||
const entries = extractRecords(type, rs.properties);
|
||||
for (const content of entries) {
|
||||
records.push({
|
||||
id: `${rs.name}::${type}::${content}`,
|
||||
type,
|
||||
name,
|
||||
content,
|
||||
ttl,
|
||||
priority: type === 'MX' ? extractMxPriority(rs.properties) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function extractRecords(type, props) {
|
||||
switch (type) {
|
||||
case 'A': return (props.ARecords ?? []).map(r => r.ipv4Address);
|
||||
case 'AAAA': return (props.AAAARecords ?? []).map(r => r.ipv6Address);
|
||||
case 'CNAME': return props.CNAMERecord ? [props.CNAMERecord.cname] : [];
|
||||
case 'MX': return (props.MXRecords ?? []).map(r => r.exchange);
|
||||
case 'NS': return (props.NSRecords ?? []).map(r => r.nsdname);
|
||||
case 'TXT': return (props.TXTRecords ?? []).map(r => r.value.join(''));
|
||||
case 'SRV': return (props.SRVRecords ?? []).map(r => `${r.priority} ${r.weight} ${r.port} ${r.target}`);
|
||||
case 'CAA': return (props.caaRecords ?? []).map(r => `${r.flags} ${r.tag} ${r.value}`);
|
||||
case 'PTR': return (props.PTRRecords ?? []).map(r => r.ptrdname);
|
||||
case 'SOA': return []; // skip SOA
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractMxPriority(props) {
|
||||
return props.MXRecords?.[0]?.preference ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record. Azure groups records into record sets by name+type,
|
||||
* so we GET the existing set (if any), append the new value, then PUT.
|
||||
*/
|
||||
async function addRecord(zoneId, record) {
|
||||
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
|
||||
// Derive relative record name
|
||||
let relName = record.name;
|
||||
if (relName.endsWith(`.${zoneName}`)) relName = relName.slice(0, -(zoneName.length + 1));
|
||||
if (relName === zoneName) relName = '@';
|
||||
|
||||
const path = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${record.type}/${relName}`;
|
||||
|
||||
// Fetch existing record set so we can merge
|
||||
let existing = null;
|
||||
try {
|
||||
existing = await armFetch(path);
|
||||
} catch {
|
||||
// 404 means it doesn't exist yet — that's fine
|
||||
}
|
||||
|
||||
const props = existing?.properties ?? { TTL: Number(record.ttl) || 3600 };
|
||||
props.TTL = Number(record.ttl) || props.TTL || 3600;
|
||||
|
||||
appendToRecordSet(props, record.type, record.content, record.priority);
|
||||
|
||||
await armFetch(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ properties: props }),
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${relName}::${record.type}::${record.content}`,
|
||||
type: record.type,
|
||||
name: relName === '@' ? zoneName : `${relName}.${zoneName}`,
|
||||
content: record.content,
|
||||
ttl: props.TTL,
|
||||
priority: record.priority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function appendToRecordSet(props, type, content, priority) {
|
||||
switch (type) {
|
||||
case 'A':
|
||||
props.ARecords = [...(props.ARecords ?? []), { ipv4Address: content }];
|
||||
break;
|
||||
case 'AAAA':
|
||||
props.AAAARecords = [...(props.AAAARecords ?? []), { ipv6Address: content }];
|
||||
break;
|
||||
case 'CNAME':
|
||||
props.CNAMERecord = { cname: content };
|
||||
break;
|
||||
case 'MX':
|
||||
props.MXRecords = [...(props.MXRecords ?? []), { preference: Number(priority) || 10, exchange: content }];
|
||||
break;
|
||||
case 'NS':
|
||||
props.NSRecords = [...(props.NSRecords ?? []), { nsdname: content }];
|
||||
break;
|
||||
case 'TXT':
|
||||
props.TXTRecords = [...(props.TXTRecords ?? []), { value: [content] }];
|
||||
break;
|
||||
case 'PTR':
|
||||
props.PTRRecords = [...(props.PTRRecords ?? []), { ptrdname: content }];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Record type ${type} is not supported for add via this adapter`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a record in-place with a single GET → modify → PUT.
|
||||
* Falls back to a plain add if the record set no longer exists in Azure.
|
||||
* recordId format: "relName::type::oldContent"
|
||||
*/
|
||||
async function updateRecord(zoneId, recordId, record) {
|
||||
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
|
||||
const parts = recordId.split('::');
|
||||
const oldRelName = parts[0];
|
||||
const type = parts[1];
|
||||
const oldContent = parts.slice(2).join('::');
|
||||
|
||||
// Derive the new relative name from the updated record
|
||||
let newRelName = record.name;
|
||||
if (newRelName.endsWith(`.${zoneName}`)) newRelName = newRelName.slice(0, -(zoneName.length + 1));
|
||||
if (newRelName === zoneName) newRelName = '@';
|
||||
|
||||
const oldPath = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${oldRelName}`;
|
||||
const newPath = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${newRelName}`;
|
||||
|
||||
// Try to fetch the existing record set
|
||||
let existing = null;
|
||||
try { existing = await armFetch(oldPath); } catch { /* doesn't exist in Azure */ }
|
||||
|
||||
if (existing) {
|
||||
const props = existing.properties;
|
||||
// Remove old value and add new one
|
||||
removeFromRecordSet(props, type, oldContent);
|
||||
props.TTL = Number(record.ttl) || props.TTL || 3600;
|
||||
appendToRecordSet(props, type, record.content, record.priority);
|
||||
|
||||
if (oldRelName === newRelName) {
|
||||
// Same name — just update in place
|
||||
await armFetch(oldPath, { method: 'PUT', body: JSON.stringify({ properties: props }) });
|
||||
} else {
|
||||
// Name changed — delete old set, create new one
|
||||
await armFetch(oldPath, { method: 'DELETE' });
|
||||
const newProps = { TTL: props.TTL };
|
||||
appendToRecordSet(newProps, type, record.content, record.priority);
|
||||
await armFetch(newPath, { method: 'PUT', body: JSON.stringify({ properties: newProps }) });
|
||||
}
|
||||
} else {
|
||||
// Record set gone from Azure — just add it fresh
|
||||
await addRecord(zoneId, record);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${newRelName}::${type}::${record.content}`,
|
||||
type,
|
||||
name: newRelName === '@' ? zoneName : `${newRelName}.${zoneName}`,
|
||||
content: record.content,
|
||||
ttl: Number(record.ttl) || 3600,
|
||||
priority: record.priority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single value from an Azure record set.
|
||||
* If the set becomes empty, the entire record set is deleted.
|
||||
* recordId format: "relName::type::content"
|
||||
*/
|
||||
async function deleteRecord(zoneId, recordId) {
|
||||
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
|
||||
const parts = recordId.split('::');
|
||||
const relName = parts[0];
|
||||
const type = parts[1];
|
||||
const content = parts.slice(2).join('::'); // content may contain "::"
|
||||
|
||||
const path = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${relName}`;
|
||||
// Fetch the record set and remove just this value
|
||||
const existing = await armFetch(path);
|
||||
const props = existing.properties;
|
||||
|
||||
removeFromRecordSet(props, type, content);
|
||||
|
||||
const remaining = countRecords(props, type);
|
||||
if (remaining === 0) {
|
||||
// Delete the entire record set
|
||||
await armFetch(path, { method: 'DELETE' });
|
||||
} else {
|
||||
await armFetch(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ properties: props }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromRecordSet(props, type, content) {
|
||||
switch (type) {
|
||||
case 'A': props.ARecords = (props.ARecords ?? []).filter(r => r.ipv4Address !== content); break;
|
||||
case 'AAAA': props.AAAARecords = (props.AAAARecords ?? []).filter(r => r.ipv6Address !== content); break;
|
||||
case 'CNAME': props.CNAMERecord = null; break;
|
||||
case 'MX': props.MXRecords = (props.MXRecords ?? []).filter(r => r.exchange !== content); break;
|
||||
case 'NS': props.NSRecords = (props.NSRecords ?? []).filter(r => r.nsdname !== content); break;
|
||||
case 'TXT': props.TXTRecords = (props.TXTRecords ?? []).filter(r => r.value.join('') !== content); break;
|
||||
case 'PTR': props.PTRRecords = (props.PTRRecords ?? []).filter(r => r.ptrdname !== content); break;
|
||||
}
|
||||
}
|
||||
|
||||
function countRecords(props, type) {
|
||||
switch (type) {
|
||||
case 'A': return (props.ARecords ?? []).length;
|
||||
case 'AAAA': return (props.AAAARecords ?? []).length;
|
||||
case 'CNAME': return props.CNAMERecord ? 1 : 0;
|
||||
case 'MX': return (props.MXRecords ?? []).length;
|
||||
case 'NS': return (props.NSRecords ?? []).length;
|
||||
case 'TXT': return (props.TXTRecords ?? []).length;
|
||||
case 'PTR': return (props.PTRRecords ?? []).length;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Cloudflare adapter — uses the Cloudflare v4 REST API.
|
||||
* Requires env: CLOUDFLARE_API_TOKEN
|
||||
*/
|
||||
|
||||
const BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function cfFetch(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, { ...options, headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
const msg = data.errors?.map(e => e.message).join(', ') || 'Cloudflare API error';
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of { id, name }
|
||||
*/
|
||||
async function listZones() {
|
||||
let page = 1;
|
||||
let allZones = [];
|
||||
while (true) {
|
||||
const result = await cfFetch(`/zones?per_page=50&page=${page}`);
|
||||
allZones = allZones.concat(result);
|
||||
if (result.length < 50) break;
|
||||
page++;
|
||||
}
|
||||
return allZones.map(z => ({ id: z.id, name: z.name }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of DNS records for a zone.
|
||||
* zoneId: the Cloudflare zone ID string
|
||||
*/
|
||||
async function listRecords(zoneId) {
|
||||
let page = 1;
|
||||
let all = [];
|
||||
while (true) {
|
||||
const result = await cfFetch(`/zones/${zoneId}/dns_records?per_page=100&page=${page}`);
|
||||
all = all.concat(result);
|
||||
if (result.length < 100) break;
|
||||
page++;
|
||||
}
|
||||
return all.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
content: r.content,
|
||||
ttl: r.ttl,
|
||||
priority: r.priority ?? null,
|
||||
proxied: r.proxied ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record. record: { type, name, content, ttl, priority?, proxied? }
|
||||
*/
|
||||
async function addRecord(zoneId, record) {
|
||||
const body = {
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl || 1,
|
||||
};
|
||||
if (record.priority !== undefined && record.priority !== '') body.priority = Number(record.priority);
|
||||
if (record.proxied !== undefined) body.proxied = record.proxied;
|
||||
|
||||
const result = await cfFetch(`/zones/${zoneId}/dns_records`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { id: result.id, ...body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing DNS record.
|
||||
*/
|
||||
async function updateRecord(zoneId, recordId, record) {
|
||||
const body = {
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl || 1,
|
||||
};
|
||||
if (record.priority !== undefined && record.priority !== '') body.priority = Number(record.priority);
|
||||
if (record.proxied !== undefined) body.proxied = record.proxied;
|
||||
|
||||
const result = await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { id: result.id, ...body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a DNS record by ID.
|
||||
*/
|
||||
async function deleteRecord(zoneId, recordId) {
|
||||
await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* cPanel adapter — uses the cPanel UAPI (user-level).
|
||||
*
|
||||
* Requires env:
|
||||
* CPANEL_URL Base URL of the cPanel instance, e.g. https://hostname:2083
|
||||
* CPANEL_USERNAME cPanel account username
|
||||
* CPANEL_API_TOKEN API token created inside cPanel (Manage API Tokens)
|
||||
*
|
||||
* The cPanel account must own the domains you want to manage.
|
||||
* API tokens are created under cPanel → Security → Manage API Tokens.
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function base() {
|
||||
return process.env.CPANEL_URL.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return `cpanel ${process.env.CPANEL_USERNAME}:${process.env.CPANEL_API_TOKEN}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal HTTP/HTTPS request helper using Node built-ins.
|
||||
* Avoids undici/fetch version conflicts and supports self-signed certs.
|
||||
*/
|
||||
function request(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const insecure = process.env.CPANEL_INSECURE === 'true';
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
const reqOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {},
|
||||
rejectUnauthorized: !insecure,
|
||||
};
|
||||
|
||||
const req = lib.request(reqOptions, res => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => { body += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, text: () => body }));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (options.body) req.write(options.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function parseResponse(res, debug = false) {
|
||||
const text = res.text();
|
||||
if (text.trimStart().startsWith('<')) {
|
||||
throw new Error(
|
||||
`cPanel returned HTTP ${res.status} with an HTML page — ` +
|
||||
`check CPANEL_URL, username, and API token. ` +
|
||||
`If cPanel uses a self-signed certificate, set CPANEL_INSECURE=true in .env`
|
||||
);
|
||||
}
|
||||
const envelope = JSON.parse(text);
|
||||
if (debug) console.log('[cpanel] full envelope:', JSON.stringify(envelope, null, 2));
|
||||
if (res.status >= 400) throw new Error(`cPanel HTTP ${res.status}`);
|
||||
|
||||
// UAPI can nest the real payload under result{} or return it flat
|
||||
const result = envelope.result ?? envelope;
|
||||
if (result.status === 0) throw new Error(`cPanel UAPI error: ${result.errors?.join(', ') ?? 'Unknown error'}`);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function uapiGet(module, func, params = {}, debug = false) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const url = `${base()}/execute/${module}/${func}${qs ? '?' + qs : ''}`;
|
||||
const res = await request(url, {
|
||||
headers: { Authorization: authHeader(), Accept: 'application/json' },
|
||||
});
|
||||
return parseResponse(res, debug);
|
||||
}
|
||||
|
||||
async function uapiPost(module, func, params = {}) {
|
||||
const body = Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
const url = `${base()}/execute/${module}/${func}`;
|
||||
const res = await request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader(),
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
body,
|
||||
});
|
||||
return parseResponse(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* cPanel API 2 call — used for write operations (add/remove zone records).
|
||||
* API 2 uses simpler flat parameters and is more widely supported.
|
||||
*/
|
||||
async function api2(func, params = {}, debug = false) {
|
||||
const qs = new URLSearchParams({
|
||||
cpanel_jsonapi_module: 'ZoneEdit',
|
||||
cpanel_jsonapi_func: func,
|
||||
cpanel_jsonapi_apiversion: '2',
|
||||
...params,
|
||||
}).toString();
|
||||
const url = `${base()}/json-api/cpanel?${qs}`;
|
||||
const res = await request(url, {
|
||||
headers: { Authorization: authHeader(), Accept: 'application/json' },
|
||||
});
|
||||
const text = res.text();
|
||||
if (text.trimStart().startsWith('<')) throw new Error('cPanel returned HTML — check credentials');
|
||||
const data = JSON.parse(text);
|
||||
const result = data?.cpanelresult?.data?.[0];
|
||||
if (result?.reason && result.reason !== 'OK' && result?.status !== 1) {
|
||||
throw new Error(`cPanel API 2 error: ${result.reason}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function listZones() {
|
||||
// Try DNS::list_zones first (cPanel v82+), fall back to DomainInfo::list_domains
|
||||
let domains = [];
|
||||
|
||||
try {
|
||||
const data = await uapiGet('DNS', 'list_zones');
|
||||
if (data && data.length > 0) {
|
||||
return data.map(z => {
|
||||
const name = typeof z === 'string' ? z : (z.domain ?? z.zone ?? z.name);
|
||||
return { id: name, name };
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!err.message.includes('could not find the function')) throw err;
|
||||
// Function doesn't exist on this cPanel version — use DomainInfo fallback
|
||||
}
|
||||
|
||||
const info = await uapiGet('DomainInfo', 'list_domains');
|
||||
// Returns { main_domain, addon_domains, parked_domains, sub_domains }
|
||||
if (info) {
|
||||
const main = info.main_domain ? [info.main_domain] : [];
|
||||
const addon = Array.isArray(info.addon_domains) ? info.addon_domains : [];
|
||||
const parked = Array.isArray(info.parked_domains) ? info.parked_domains : [];
|
||||
domains = [...new Set([...main, ...addon, ...parked])];
|
||||
}
|
||||
|
||||
return domains.map(d => ({ id: d, name: d }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists DNS records for a zone by parsing it with UAPI.
|
||||
*/
|
||||
async function listRecords(domain) {
|
||||
// Use API 2 fetchzone_records — returns pre-decoded values and the correct
|
||||
// Line numbers that edit_zone_record / remove_zone_record expect.
|
||||
const qs = new URLSearchParams({
|
||||
cpanel_jsonapi_module: 'ZoneEdit',
|
||||
cpanel_jsonapi_func: 'fetchzone_records',
|
||||
cpanel_jsonapi_apiversion: '2',
|
||||
domain,
|
||||
}).toString();
|
||||
const url = `${base()}/json-api/cpanel?${qs}`;
|
||||
const res = await request(url, {
|
||||
headers: { Authorization: authHeader(), Accept: 'application/json' },
|
||||
});
|
||||
const text = res.text();
|
||||
const envelope = JSON.parse(text);
|
||||
const records = envelope?.cpanelresult?.data ?? [];
|
||||
|
||||
const SKIP_TYPES = new Set(['SOA', 'NS', '$TTL', ':RAW']);
|
||||
|
||||
return records
|
||||
.filter(r => r.type && !SKIP_TYPES.has(r.type) && !r.type.startsWith('$') && !r.type.startsWith(':'))
|
||||
.map(r => {
|
||||
const rawName = (r.name ?? '').replace(/\.$/, '');
|
||||
const name = rawName || domain;
|
||||
const content = extractContent(r);
|
||||
return {
|
||||
id: `${r.Line}::${r.type}::${name}`,
|
||||
type: r.type,
|
||||
name,
|
||||
content,
|
||||
ttl: r.ttl ?? null,
|
||||
priority: r.type === 'MX' ? (Number(r.preference) || null) : null,
|
||||
_line: r.Line,
|
||||
};
|
||||
})
|
||||
.filter(r => r.content !== null);
|
||||
}
|
||||
|
||||
function extractContent(r) {
|
||||
switch (r.type) {
|
||||
case 'A':
|
||||
case 'AAAA': return r.address ?? null;
|
||||
case 'CNAME': return (r.cname ?? '').replace(/\.$/, '') || null;
|
||||
case 'MX': return (r.exchange ?? '').replace(/\.$/, '') || null;
|
||||
case 'TXT': return Array.isArray(r.txtdata) ? r.txtdata.join('') : (r.txtdata ?? null);
|
||||
case 'NS': return (r.nsdname ?? '').replace(/\.$/, '') || null;
|
||||
case 'PTR': return (r.ptrdname ?? '').replace(/\.$/, '') || null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record using mass_edit_zone.
|
||||
* Fetches the current serial first (required by the API).
|
||||
*/
|
||||
async function addRecord(domain, record) {
|
||||
let name = record.name;
|
||||
if (name.endsWith(`.${domain}`)) name = name.slice(0, -(domain.length + 1));
|
||||
if (name === domain) name = domain + '.'; // apex needs FQDN with trailing dot for API 2
|
||||
|
||||
const params = { domain, name, type: record.type, ttl: record.ttl || 3600 };
|
||||
|
||||
switch (record.type) {
|
||||
case 'A':
|
||||
case 'AAAA': params.address = record.content; break;
|
||||
case 'CNAME': params.cname = record.content; break;
|
||||
case 'MX': params.exchange = record.content; params.priority = record.priority ?? 10; break;
|
||||
case 'TXT': params.txtdata = record.content; break;
|
||||
case 'NS': params.nsdname = record.content; break;
|
||||
case 'PTR': params.ptrdname = record.content; break;
|
||||
default: throw new Error(`Unsupported record type for cPanel add: ${record.type}`);
|
||||
}
|
||||
|
||||
await api2('add_zone_record', params);
|
||||
|
||||
// Re-parse to find the newly created line index
|
||||
const all = await listRecords(domain);
|
||||
const expectedName = (name === domain + '.') ? domain : `${name}.${domain}`;
|
||||
const created = all.find(r =>
|
||||
r.type === record.type &&
|
||||
r.content === record.content &&
|
||||
r.name === expectedName
|
||||
);
|
||||
|
||||
return created ?? {
|
||||
id: `new::${record.type}::${record.content}`,
|
||||
type: record.type,
|
||||
name: expectedName,
|
||||
content: record.content,
|
||||
ttl: record.ttl ?? 3600,
|
||||
priority: record.priority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a record — delete old line, add new record.
|
||||
*/
|
||||
async function updateRecord(domain, recordId, record) {
|
||||
// cPanel's edit_zone_record adds instead of replacing on some versions.
|
||||
// Delete the old record by line number, then add the new one.
|
||||
await deleteRecord(domain, recordId);
|
||||
return addRecord(domain, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a record by its line number.
|
||||
* recordId format: "lineNumber::type::name"
|
||||
*/
|
||||
async function deleteRecord(domain, recordId) {
|
||||
const lineIndex = parseInt(recordId.split('::')[0]);
|
||||
await api2('remove_zone_record', { domain, line: lineIndex });
|
||||
}
|
||||
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Loopia adapter — uses the Loopia XML-RPC API.
|
||||
* Requires env: LOOPIA_USER, LOOPIA_PASSWORD
|
||||
*
|
||||
* API docs: https://www.loopia.se/api/
|
||||
* Endpoint: https://api.loopia.se/RPCSERV
|
||||
*/
|
||||
|
||||
const { parseStringPromise } = require('xml2js');
|
||||
|
||||
const ENDPOINT = 'https://api.loopia.se/RPCSERV';
|
||||
|
||||
// ─── XML-RPC builder ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialise a JS value to an XML-RPC <value> string.
|
||||
*/
|
||||
function valueXml(v) {
|
||||
if (v === null || v === undefined) return '<value><string></string></value>';
|
||||
if (typeof v === 'boolean') return `<value><boolean>${v ? 1 : 0}</boolean></value>`;
|
||||
if (typeof v === 'number') {
|
||||
return Number.isInteger(v)
|
||||
? `<value><int>${v}</int></value>`
|
||||
: `<value><double>${v}</double></value>`;
|
||||
}
|
||||
if (typeof v === 'string') return `<value><string>${xmlEscape(v)}</string></value>`;
|
||||
if (Array.isArray(v)) {
|
||||
return `<value><array><data>${v.map(valueXml).join('')}</data></array></value>`;
|
||||
}
|
||||
if (typeof v === 'object') {
|
||||
const members = Object.entries(v)
|
||||
.map(([k, val]) => `<member><name>${xmlEscape(k)}</name>${valueXml(val)}</member>`)
|
||||
.join('');
|
||||
return `<value><struct>${members}</struct></value>`;
|
||||
}
|
||||
return `<value><string>${xmlEscape(String(v))}</string></value>`;
|
||||
}
|
||||
|
||||
function xmlEscape(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildRequest(method, args) {
|
||||
const params = args
|
||||
.map(a => `<param>${valueXml(a)}</param>`)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><methodCall><methodName>${method}</methodName><params>${params}</params></methodCall>`;
|
||||
}
|
||||
|
||||
// ─── XML-RPC response parser ─────────────────────────────────────────────────
|
||||
|
||||
function parseValue(node) {
|
||||
if (node.string !== undefined) return Array.isArray(node.string) ? node.string[0] : node.string;
|
||||
if (node.int !== undefined) return parseInt(Array.isArray(node.int) ? node.int[0] : node.int);
|
||||
if (node.i4 !== undefined) return parseInt(Array.isArray(node.i4) ? node.i4[0] : node.i4);
|
||||
if (node.boolean !== undefined) {
|
||||
const b = Array.isArray(node.boolean) ? node.boolean[0] : node.boolean;
|
||||
return b === '1' || b === 1 || b === true;
|
||||
}
|
||||
if (node.double !== undefined) return parseFloat(Array.isArray(node.double) ? node.double[0] : node.double);
|
||||
if (node.array) {
|
||||
const data = node.array[0]?.data?.[0]?.value ?? [];
|
||||
return data.map(parseValue);
|
||||
}
|
||||
if (node.struct) {
|
||||
const obj = {};
|
||||
for (const member of node.struct[0].member ?? []) {
|
||||
obj[member.name[0]] = parseValue(member.value[0]);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
// bare string (no type tag)
|
||||
const keys = Object.keys(node).filter(k => k !== '_');
|
||||
if (keys.length > 0) {
|
||||
const v = node[keys[0]];
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
}
|
||||
return node._ ?? null;
|
||||
}
|
||||
|
||||
// ─── Core RPC call ───────────────────────────────────────────────────────────
|
||||
|
||||
async function xmlRpc(method, args) {
|
||||
const body = buildRequest(method, args);
|
||||
console.log(`[loopia] → ${method}`);
|
||||
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
|
||||
body,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseStringPromise(text, { explicitArray: true });
|
||||
} catch (e) {
|
||||
throw new Error(`Loopia returned invalid XML: ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const fault = parsed?.methodResponse?.fault;
|
||||
if (fault) {
|
||||
const fv = parseValue(fault[0].value[0]);
|
||||
throw new Error(`Loopia fault ${fv.faultCode}: ${fv.faultString}`);
|
||||
}
|
||||
|
||||
const param = parsed?.methodResponse?.params?.[0]?.param?.[0]?.value?.[0];
|
||||
const result = param ? parseValue(param) : null;
|
||||
|
||||
// Loopia returns status strings like "OK", "AUTH_ERROR", etc. for write calls
|
||||
if (typeof result === 'string' && result !== 'OK' && !/^\d+$/.test(result)) {
|
||||
throw new Error(`Loopia returned status: ${result}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Credential helper ───────────────────────────────────────────────────────
|
||||
|
||||
function creds() {
|
||||
return [process.env.LOOPIA_USER, process.env.LOOPIA_PASSWORD];
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function listZones() {
|
||||
const domains = await xmlRpc('getDomains', creds());
|
||||
if (!Array.isArray(domains)) throw new Error(`getDomains returned unexpected value: ${JSON.stringify(domains)}`);
|
||||
return domains.map(d => ({ id: d.domain, name: d.domain }));
|
||||
}
|
||||
|
||||
async function listRecords(domain) {
|
||||
const subdomains = await xmlRpc('getSubdomains', [...creds(), domain]);
|
||||
if (!Array.isArray(subdomains)) throw new Error(`getSubdomains returned unexpected value: ${JSON.stringify(subdomains)}`);
|
||||
|
||||
const allRecords = [];
|
||||
for (const sub of subdomains) {
|
||||
const records = await xmlRpc('getZoneRecords', [...creds(), domain, sub]);
|
||||
if (!Array.isArray(records)) continue;
|
||||
for (const r of records) {
|
||||
allRecords.push({
|
||||
id: `${sub}::${r.record_id}`,
|
||||
type: r.type,
|
||||
name: sub === '@' ? domain : `${sub}.${domain}`,
|
||||
content: r.rdata,
|
||||
ttl: r.ttl,
|
||||
priority: r.priority ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
async function addRecord(domain, record) {
|
||||
let subdomain = record.name;
|
||||
if (subdomain.endsWith(`.${domain}`)) subdomain = subdomain.slice(0, -(domain.length + 1));
|
||||
if (subdomain === domain || subdomain === '') subdomain = '@';
|
||||
|
||||
const entry = {
|
||||
type: record.type,
|
||||
ttl: Number(record.ttl) || 3600,
|
||||
priority: Number(record.priority) || 0,
|
||||
rdata: record.content,
|
||||
};
|
||||
|
||||
await xmlRpc('addZoneRecord', [...creds(), domain, subdomain, entry]);
|
||||
|
||||
const displayName = subdomain === '@' ? domain : `${subdomain}.${domain}`;
|
||||
return {
|
||||
id: `${subdomain}::new`,
|
||||
type: record.type,
|
||||
name: displayName,
|
||||
content: record.content,
|
||||
ttl: entry.ttl,
|
||||
priority: entry.priority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateRecord(domain, recordId, record) {
|
||||
await deleteRecord(domain, recordId);
|
||||
return addRecord(domain, record);
|
||||
}
|
||||
|
||||
async function deleteRecord(domain, recordId) {
|
||||
const [subdomain, loopiaId] = recordId.split('::');
|
||||
await xmlRpc('removeZoneRecord', [...creds(), domain, subdomain, parseInt(loopiaId)]);
|
||||
|
||||
// If no records remain on this subdomain, remove the subdomain itself.
|
||||
// Skip '@' — that's the root zone and should never be removed.
|
||||
if (subdomain !== '@') {
|
||||
const remaining = await xmlRpc('getZoneRecords', [...creds(), domain, subdomain]);
|
||||
if (!Array.isArray(remaining) || remaining.length === 0) {
|
||||
await xmlRpc('removeSubdomain', [...creds(), domain, subdomain]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Pi-hole v6 adapter — uses the Pi-hole v6 REST API.
|
||||
* Supports custom A/AAAA records and CNAME records (Pi-hole local DNS).
|
||||
*
|
||||
* Requires env:
|
||||
* PIHOLE_URL e.g. http://192.168.1.2 (no trailing slash)
|
||||
* PIHOLE_PASSWORD your Pi-hole web password
|
||||
*
|
||||
* Pi-hole v6 docs: https://ftl.pi-hole.net/
|
||||
*
|
||||
* Note: Pi-hole local DNS has no zones or TTL — the whole instance is
|
||||
* treated as a single zone. Record types are limited to A, AAAA, CNAME.
|
||||
*/
|
||||
|
||||
let cachedSid = null;
|
||||
let sidExpiry = 0;
|
||||
|
||||
function base() {
|
||||
return process.env.PIHOLE_URL.replace(/\/$/, '') + '/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and return a session ID, reusing a cached one if still valid.
|
||||
*/
|
||||
async function getSession() {
|
||||
if (cachedSid && Date.now() < sidExpiry) return cachedSid;
|
||||
|
||||
const res = await fetch(`${base()}/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: process.env.PIHOLE_PASSWORD }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Pi-hole auth failed: ${res.status} ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.session?.valid) {
|
||||
throw new Error('Pi-hole authentication failed — check PIHOLE_PASSWORD');
|
||||
}
|
||||
|
||||
cachedSid = data.session.sid;
|
||||
// Sessions last 5 minutes by default; refresh 30s early
|
||||
sidExpiry = Date.now() + (data.session.validity ?? 300) * 1000 - 30_000;
|
||||
return cachedSid;
|
||||
}
|
||||
|
||||
async function phFetch(path, options = {}) {
|
||||
const sid = await getSession();
|
||||
const res = await fetch(`${base()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-FTL-SID': sid,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache on auth errors so next call re-authenticates
|
||||
if (res.status === 401) {
|
||||
cachedSid = null;
|
||||
throw new Error('Pi-hole session expired — will re-authenticate on next request');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Pi-hole API error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single "zone" representing the Pi-hole instance.
|
||||
*/
|
||||
async function listZones() {
|
||||
return [{ id: 'local', name: process.env.PIHOLE_URL || 'Pi-hole' }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all custom DNS (A/AAAA) and CNAME records.
|
||||
*
|
||||
* Pi-hole v6 stores these under /api/config/dns/hosts and
|
||||
* /api/config/dns/cnameRecords.
|
||||
*
|
||||
* hosts entries are plain strings: "1.2.3.4 hostname"
|
||||
* cnameRecords entries are plain strings: "alias,target"
|
||||
*/
|
||||
async function listRecords(_zoneId) {
|
||||
const [hostsData, cnameData] = await Promise.all([
|
||||
phFetch('/config/dns/hosts'),
|
||||
phFetch('/config/dns/cnameRecords'),
|
||||
]);
|
||||
|
||||
const records = [];
|
||||
|
||||
for (const entry of hostsData?.config?.dns?.hosts ?? []) {
|
||||
// entry: "1.2.3.4 hostname" or "::1 hostname"
|
||||
const spaceIdx = entry.indexOf(' ');
|
||||
if (spaceIdx === -1) continue;
|
||||
const ip = entry.slice(0, spaceIdx).trim();
|
||||
const domain = entry.slice(spaceIdx + 1).trim();
|
||||
const type = ip.includes(':') ? 'AAAA' : 'A';
|
||||
records.push({
|
||||
id: `dns::${domain}::${ip}`,
|
||||
type,
|
||||
name: domain,
|
||||
content: ip,
|
||||
ttl: null,
|
||||
priority: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of cnameData?.config?.dns?.cnameRecords ?? []) {
|
||||
// entry: "alias,target"
|
||||
const [alias, target] = entry.split(',');
|
||||
if (!alias || !target) continue;
|
||||
records.push({
|
||||
id: `cname::${alias.trim()}::${target.trim()}`,
|
||||
type: 'CNAME',
|
||||
name: alias.trim(),
|
||||
content: target.trim(),
|
||||
ttl: null,
|
||||
priority: null,
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom DNS or CNAME record.
|
||||
* record: { type, name, content }
|
||||
*/
|
||||
async function addRecord(_zoneId, record) {
|
||||
const type = record.type.toUpperCase();
|
||||
|
||||
if (type === 'CNAME') {
|
||||
const entry = encodeURIComponent(`${record.name},${record.content}`);
|
||||
await phFetch(`/config/dns/cnameRecords/${entry}`, { method: 'PUT' });
|
||||
return { id: `cname::${record.name}::${record.content}`, type: 'CNAME', name: record.name, content: record.content };
|
||||
}
|
||||
|
||||
if (type === 'A' || type === 'AAAA') {
|
||||
const entry = encodeURIComponent(`${record.content} ${record.name}`);
|
||||
await phFetch(`/config/dns/hosts/${entry}`, { method: 'PUT' });
|
||||
return { id: `dns::${record.name}::${record.content}`, type, name: record.name, content: record.content };
|
||||
}
|
||||
|
||||
throw new Error(`Pi-hole only supports A, AAAA, and CNAME records (got ${type})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a record — Pi-hole has no native update, so delete + re-add.
|
||||
*/
|
||||
async function updateRecord(zoneId, recordId, record) {
|
||||
await deleteRecord(zoneId, recordId);
|
||||
return addRecord(zoneId, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a record by composite ID.
|
||||
* recordId format: "dns::domain::ip" or "cname::alias::target"
|
||||
*/
|
||||
async function deleteRecord(_zoneId, recordId) {
|
||||
const [kind, part1, part2] = recordId.split('::');
|
||||
|
||||
if (kind === 'cname') {
|
||||
const entry = encodeURIComponent(`${part1},${part2}`);
|
||||
await phFetch(`/config/dns/cnameRecords/${entry}`, { method: 'DELETE' });
|
||||
} else {
|
||||
const entry = encodeURIComponent(`${part2} ${part1}`);
|
||||
await phFetch(`/config/dns/hosts/${entry}`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,118 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const AUDIT_PATH = process.env.AUDIT_PATH || path.join(__dirname, '..', 'audit-log.json');
|
||||
const MAX_ENTRIES = 500;
|
||||
|
||||
function load() {
|
||||
try { return JSON.parse(fs.readFileSync(AUDIT_PATH, 'utf8')); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function save(entries) {
|
||||
fs.writeFileSync(AUDIT_PATH, JSON.stringify(entries, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function append(entry) {
|
||||
const entries = load();
|
||||
entries.unshift(entry);
|
||||
if (entries.length > MAX_ENTRIES) entries.splice(MAX_ENTRIES);
|
||||
save(entries);
|
||||
}
|
||||
|
||||
// ─── DNS record changes ───────────────────────────────────────────────────────
|
||||
|
||||
function log(user, action, provider, zone, record, prev = null) {
|
||||
append({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
category: 'dns',
|
||||
user: { id: user.id, username: user.username },
|
||||
action,
|
||||
provider,
|
||||
zone,
|
||||
record: {
|
||||
type: record?.type ?? null,
|
||||
name: record?.name ?? null,
|
||||
content: record?.content ?? null,
|
||||
},
|
||||
prev: prev ? {
|
||||
type: prev?.type ?? null,
|
||||
name: prev?.name ?? null,
|
||||
content: prev?.content ?? null,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Secrets changes ──────────────────────────────────────────────────────────
|
||||
|
||||
function logSecret(user, action, secret, prev = null) {
|
||||
append({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
category: 'secret',
|
||||
user: { id: user.id, username: user.username },
|
||||
action,
|
||||
target: {
|
||||
name: secret?.name ?? null,
|
||||
type: secret?.type ?? null,
|
||||
expires: secret?.expires_at ?? null,
|
||||
},
|
||||
prev: prev ? {
|
||||
name: prev?.name ?? null,
|
||||
type: prev?.type ?? null,
|
||||
expires: prev?.expires_at ?? null,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── IPAM changes ─────────────────────────────────────────────────────────────
|
||||
|
||||
function logIpam(user, action, entry, prev = null) {
|
||||
append({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
category: 'ipam',
|
||||
user: { id: user.id, username: user.username },
|
||||
action,
|
||||
target: {
|
||||
address: entry?.address ?? null,
|
||||
label: entry?.label ?? null,
|
||||
vendor: entry?.vendor ?? null,
|
||||
},
|
||||
prev: prev ? {
|
||||
address: prev?.address ?? null,
|
||||
label: prev?.label ?? null,
|
||||
vendor: prev?.vendor ?? null,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── User management changes ──────────────────────────────────────────────────
|
||||
|
||||
function logUser(actor, action, targetUser) {
|
||||
append({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
category: 'user',
|
||||
user: { id: actor.id, username: actor.username },
|
||||
action,
|
||||
target: { id: targetUser?.id ?? null, username: targetUser?.username ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Query ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getEntries({ limit = 100, offset = 0, user, action, provider, category } = {}) {
|
||||
let entries = load();
|
||||
|
||||
if (user) entries = entries.filter(e => e.user.username.toLowerCase().includes(user.toLowerCase()));
|
||||
if (action) entries = entries.filter(e => e.action === action);
|
||||
if (category) entries = entries.filter(e => e.category === category);
|
||||
if (provider) entries = entries.filter(e => e.provider === provider);
|
||||
|
||||
const total = entries.length;
|
||||
return { total, entries: entries.slice(offset, offset + limit) };
|
||||
}
|
||||
|
||||
module.exports = { log, logSecret, logIpam, logUser, getEntries };
|
||||
@@ -0,0 +1,40 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
function getSecret() {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.warn('⚠️ JWT_SECRET not set in .env — using an insecure default. Set a strong random secret.');
|
||||
return 'dns-manager-insecure-default-secret';
|
||||
}
|
||||
return process.env.JWT_SECRET;
|
||||
}
|
||||
|
||||
function signToken(user) {
|
||||
return jwt.sign(
|
||||
{ id: user.id, username: user.username },
|
||||
getSecret(),
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
|
||||
);
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
return jwt.verify(token, getSecret());
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware — rejects requests without a valid JWT.
|
||||
*/
|
||||
function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
req.user = verifyToken(token);
|
||||
next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { signToken, verifyToken, requireAuth };
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Simple JSON file-based cache for DNS records.
|
||||
* No native dependencies — works on any Node version.
|
||||
*
|
||||
* Structure of dns-cache.json:
|
||||
* {
|
||||
* "cloudflare": {
|
||||
* "zone123": {
|
||||
* "synced_at": "2024-01-01T00:00:00.000Z",
|
||||
* "records": [ { id, type, name, content, ttl, priority }, ... ]
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CACHE_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'dns-cache.json');
|
||||
|
||||
// ─── Load / save ─────────────────────────────────────────────────────────────
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function save(data) {
|
||||
fs.writeFileSync(CACHE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getRecords(provider, zoneId) {
|
||||
const data = load();
|
||||
return data[provider]?.[zoneId]?.records ?? [];
|
||||
}
|
||||
|
||||
function getSyncedAt(provider, zoneId) {
|
||||
const data = load();
|
||||
return data[provider]?.[zoneId]?.synced_at ?? null;
|
||||
}
|
||||
|
||||
// Returns { [zoneId]: { synced_at, record_count } } for all cached zones of a provider
|
||||
function getProviderSyncStatus(provider) {
|
||||
const data = load();
|
||||
const zones = data[provider] ?? {};
|
||||
const result = {};
|
||||
for (const [zoneId, zone] of Object.entries(zones)) {
|
||||
result[zoneId] = {
|
||||
synced_at: zone.synced_at ?? null,
|
||||
record_count: (zone.records ?? []).length,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getZoneName(provider, zoneId) {
|
||||
const data = load();
|
||||
return data[provider]?.[zoneId]?.zone_name ?? zoneId;
|
||||
}
|
||||
|
||||
function replaceZoneRecords(provider, zoneId, records, zoneName) {
|
||||
const data = load();
|
||||
if (!data[provider]) data[provider] = {};
|
||||
data[provider][zoneId] = {
|
||||
zone_name: zoneName ?? data[provider]?.[zoneId]?.zone_name ?? zoneId,
|
||||
synced_at: new Date().toISOString(),
|
||||
records: records.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
content: r.content,
|
||||
ttl: r.ttl ?? null,
|
||||
priority: r.priority ?? null,
|
||||
})),
|
||||
};
|
||||
save(data);
|
||||
}
|
||||
|
||||
function upsertRecord(provider, zoneId, record) {
|
||||
const data = load();
|
||||
if (!data[provider]) data[provider] = {};
|
||||
if (!data[provider][zoneId]) data[provider][zoneId] = { synced_at: null, records: [] };
|
||||
|
||||
const records = data[provider][zoneId].records;
|
||||
const idx = records.findIndex(r => r.id === record.id);
|
||||
const entry = {
|
||||
id: record.id,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl ?? null,
|
||||
priority: record.priority ?? null,
|
||||
};
|
||||
|
||||
if (idx >= 0) records[idx] = entry;
|
||||
else records.push(entry);
|
||||
|
||||
save(data);
|
||||
}
|
||||
|
||||
function deleteRecord(provider, zoneId, recordId) {
|
||||
const data = load();
|
||||
const zone = data[provider]?.[zoneId];
|
||||
if (!zone) return;
|
||||
zone.records = zone.records.filter(r => r.id !== recordId);
|
||||
save(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all cached zones across all providers as a flat list.
|
||||
* Groups duplicate zone names so you can spot the same domain on multiple providers.
|
||||
*/
|
||||
function getAllZones() {
|
||||
const data = load();
|
||||
const byName = {};
|
||||
|
||||
for (const [provider, zones] of Object.entries(data)) {
|
||||
for (const [zoneId, zone] of Object.entries(zones)) {
|
||||
const name = zone.zone_name ?? zoneId;
|
||||
if (!byName[name]) byName[name] = [];
|
||||
byName[name].push({
|
||||
provider,
|
||||
zone_id: zoneId,
|
||||
record_count: (zone.records ?? []).length,
|
||||
synced_at: zone.synced_at ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(byName)
|
||||
.map(([name, entries]) => ({ name, entries, duplicate: entries.length > 1 }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const data = load();
|
||||
const typeCounts = {};
|
||||
let totalZones = 0;
|
||||
let zonesWithMx = 0;
|
||||
const perProvider = {};
|
||||
|
||||
for (const [provider, zones] of Object.entries(data)) {
|
||||
const zoneCount = Object.keys(zones).length;
|
||||
totalZones += zoneCount;
|
||||
perProvider[provider] = { zones: zoneCount };
|
||||
|
||||
for (const zone of Object.values(zones)) {
|
||||
const records = zone.records ?? [];
|
||||
const hasMx = records.some(r => r.type === 'MX');
|
||||
if (hasMx) zonesWithMx++;
|
||||
|
||||
for (const r of records) {
|
||||
typeCounts[r.type] = (typeCounts[r.type] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recordTypes = Object.entries(typeCounts)
|
||||
.map(([type, count]) => ({ type, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { totalZones, zonesWithMx, recordTypes, perProvider };
|
||||
}
|
||||
|
||||
module.exports = { getRecords, getSyncedAt, getZoneName, getProviderSyncStatus, getAllZones, replaceZoneRecords, upsertRecord, deleteRecord, getStats };
|
||||
@@ -0,0 +1,117 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const { requireAuth } = require('./auth');
|
||||
const { ensureDefaultAdmin } = require('./users');
|
||||
const audit = require('./audit');
|
||||
const secrets = require('./secrets');
|
||||
const schedule = require('node-schedule');
|
||||
|
||||
const zonesRouter = require('./routes/zones');
|
||||
const secretsRouter = require('./routes/secrets');
|
||||
const ipamRouter = require('./routes/ipam');
|
||||
const healthRouter = require('./routes/health');
|
||||
const recordsRouter = require('./routes/records');
|
||||
const settingsRouter = require('./routes/settings');
|
||||
const authRouter = require('./routes/auth');
|
||||
const usersRouter = require('./routes/users');
|
||||
const db = require('./db');
|
||||
|
||||
// Create default admin user if none exist
|
||||
ensureDefaultAdmin();
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// ─── Public routes (no auth required) ────────────────────────────────────────
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
// ─── Protected routes (JWT required) ─────────────────────────────────────────
|
||||
app.use('/api/zones', requireAuth, zonesRouter);
|
||||
app.use('/api/records', requireAuth, recordsRouter);
|
||||
app.use('/api/settings', requireAuth, settingsRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/secrets', secretsRouter);
|
||||
app.use('/api/ipam', ipamRouter);
|
||||
app.use('/api/health', healthRouter); // requireAuth applied inside router
|
||||
|
||||
app.get('/api/audit', requireAuth, (req, res) => {
|
||||
const { limit, offset, user, action, provider, category } = req.query;
|
||||
res.json(audit.getEntries({
|
||||
limit: Math.min(parseInt(limit) || 100, 500),
|
||||
offset: parseInt(offset) || 0,
|
||||
user, action, provider, category,
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/api/sync-status/:provider', requireAuth, (req, res) => {
|
||||
res.json(db.getProviderSyncStatus(req.params.provider));
|
||||
});
|
||||
|
||||
app.get('/api/domains', requireAuth, (req, res) => {
|
||||
res.json(db.getAllZones());
|
||||
});
|
||||
|
||||
app.get('/api/stats', requireAuth, (req, res) => {
|
||||
res.json(db.getStats());
|
||||
});
|
||||
|
||||
app.get('/api/providers', requireAuth, (req, res) => {
|
||||
const providers = [];
|
||||
const disabled = new Set((process.env.DISABLED_PROVIDERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
|
||||
|
||||
if (!disabled.has('cloudflare') && process.env.CLOUDFLARE_API_TOKEN) providers.push({ id: 'cloudflare', name: 'Cloudflare' });
|
||||
if (!disabled.has('loopia') && process.env.LOOPIA_USER && process.env.LOOPIA_PASSWORD) providers.push({ id: 'loopia', name: 'Loopia' });
|
||||
if (!disabled.has('pihole') && process.env.PIHOLE_URL && process.env.PIHOLE_PASSWORD) providers.push({ id: 'pihole', name: 'Pi-hole', url: process.env.PIHOLE_URL });
|
||||
if (!disabled.has('azure') && process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET && process.env.AZURE_SUBSCRIPTION_ID) providers.push({ id: 'azure', name: 'Azure DNS' });
|
||||
if (!disabled.has('cpanel') && process.env.CPANEL_URL && process.env.CPANEL_USERNAME && process.env.CPANEL_API_TOKEN) providers.push({ id: 'cpanel', name: 'cPanel', url: process.env.CPANEL_URL });
|
||||
res.json(providers);
|
||||
});
|
||||
|
||||
// ─── Daily secrets expiry check (runs at 08:00 every day) ────────────────────
|
||||
const { notify } = require('./notify');
|
||||
|
||||
const LAST_CHECK_PATH = require('path').join(__dirname, '..', '.last-secret-check');
|
||||
|
||||
function getLastCheckDate() {
|
||||
try { return require('fs').readFileSync(LAST_CHECK_PATH, 'utf8').trim(); } catch { return null; }
|
||||
}
|
||||
|
||||
function saveLastCheckDate(date) {
|
||||
require('fs').writeFileSync(LAST_CHECK_PATH, date, 'utf8');
|
||||
}
|
||||
|
||||
async function checkSecretExpiry() {
|
||||
const expiring = secrets.getExpiring();
|
||||
if (expiring.length === 0) return;
|
||||
|
||||
const lines = expiring.map(s =>
|
||||
s.status === 'expired'
|
||||
? `✕ EXPIRED — ${s.name}`
|
||||
: `⚠ ${s.daysLeft}d left — ${s.name}`
|
||||
);
|
||||
|
||||
await notify(
|
||||
`🦥 Sloth Manager — Secrets Alert`,
|
||||
`${expiring.length} secret${expiring.length !== 1 ? 's' : ''} need attention:\n\n${lines.join('\n')}`
|
||||
);
|
||||
}
|
||||
|
||||
async function checkSecretExpiryOnce() {
|
||||
const today = new Date().toDateString();
|
||||
if (getLastCheckDate() === today) return; // already ran today, persisted to disk
|
||||
saveLastCheckDate(today);
|
||||
await checkSecretExpiry();
|
||||
}
|
||||
|
||||
// Run at startup only if not already checked today (persisted to disk), then every day at 08:00
|
||||
checkSecretExpiryOnce();
|
||||
schedule.scheduleJob('0 8 * * *', () => {
|
||||
saveLastCheckDate(''); // clear so the scheduled job always fires
|
||||
checkSecretExpiry();
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => console.log(`Sloth Manager backend running on port ${PORT}`));
|
||||
@@ -0,0 +1,95 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const IPAM_PATH = process.env.IPAM_PATH || path.join(__dirname, '..', 'ipam.json');
|
||||
const CACHE_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'dns-cache.json');
|
||||
|
||||
function load() {
|
||||
try { return JSON.parse(fs.readFileSync(IPAM_PATH, 'utf8')); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function save(entries) {
|
||||
fs.writeFileSync(IPAM_PATH, JSON.stringify(entries, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the DNS cache for A/AAAA records whose content matches a given IP.
|
||||
*/
|
||||
function findDnsMatches(address) {
|
||||
const matches = [];
|
||||
let cache = {};
|
||||
try { cache = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); } catch { }
|
||||
|
||||
for (const [provider, zones] of Object.entries(cache)) {
|
||||
for (const [zoneId, zone] of Object.entries(zones)) {
|
||||
const zoneName = zone.zone_name ?? zoneId;
|
||||
for (const record of zone.records ?? []) {
|
||||
if (['A', 'AAAA'].includes(record.type) && record.content === address) {
|
||||
// Ensure we always show the full FQDN, not just a relative label
|
||||
const fullName = record.name && record.name.includes('.')
|
||||
? record.name
|
||||
: record.name
|
||||
? `${record.name}.${zoneName}`
|
||||
: zoneName;
|
||||
matches.push({ provider, zone: zoneName, type: record.type, name: fullName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return load().map(e => ({ ...e, dnsMatches: findDnsMatches(e.address) }));
|
||||
}
|
||||
|
||||
function getById(id) {
|
||||
const entry = load().find(e => e.id === id);
|
||||
if (!entry) return null;
|
||||
return { ...entry, dnsMatches: findDnsMatches(entry.address) };
|
||||
}
|
||||
|
||||
function create(data) {
|
||||
const entries = load();
|
||||
if (!data.address) throw new Error('IP address is required');
|
||||
const entry = {
|
||||
id: Date.now().toString(),
|
||||
address: data.address.trim(),
|
||||
label: data.label || '',
|
||||
vendor: data.vendor || '',
|
||||
location: data.location || '',
|
||||
notes: data.notes || '',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
entries.push(entry);
|
||||
save(entries);
|
||||
return { ...entry, dnsMatches: findDnsMatches(entry.address) };
|
||||
}
|
||||
|
||||
function update(id, data) {
|
||||
const entries = load();
|
||||
const idx = entries.findIndex(e => e.id === id);
|
||||
if (idx === -1) throw new Error('Entry not found');
|
||||
entries[idx] = {
|
||||
...entries[idx],
|
||||
address: data.address !== undefined ? data.address.trim() : entries[idx].address,
|
||||
label: data.label !== undefined ? data.label : entries[idx].label,
|
||||
vendor: data.vendor !== undefined ? data.vendor : entries[idx].vendor,
|
||||
location: data.location !== undefined ? data.location : entries[idx].location,
|
||||
notes: data.notes !== undefined ? data.notes : entries[idx].notes,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
save(entries);
|
||||
return { ...entries[idx], dnsMatches: findDnsMatches(entries[idx].address) };
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const entries = load();
|
||||
const filtered = entries.filter(e => e.id !== id);
|
||||
if (filtered.length === entries.length) throw new Error('Entry not found');
|
||||
save(filtered);
|
||||
}
|
||||
|
||||
module.exports = { getAll, getById, create, update, remove };
|
||||
@@ -0,0 +1,61 @@
|
||||
const settings = require('./settings');
|
||||
const db = require('./db');
|
||||
|
||||
/**
|
||||
* Send a Gotify notification if enabled and configured.
|
||||
* Errors are logged but never thrown — notifications are best-effort.
|
||||
*/
|
||||
async function notify(title, message) {
|
||||
const { gotify } = settings.get();
|
||||
|
||||
if (!gotify.enabled || !gotify.url || !gotify.token) return;
|
||||
|
||||
const base = gotify.url.replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}/message?token=${encodeURIComponent(gotify.token)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
priority: gotify.priority ?? 5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error(`[notify] Gotify error ${res.status}: ${text}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[notify] Failed to send Gotify notification:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Convenience helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function recordAdded(provider, zoneId, record) {
|
||||
const zoneName = db.getZoneName(provider, zoneId);
|
||||
return notify(
|
||||
`DNS Record Added`,
|
||||
`[${provider}] ${zoneName}\n+ ${record.type} ${record.name} → ${record.content}`,
|
||||
);
|
||||
}
|
||||
|
||||
function recordUpdated(provider, zoneId, oldRecord, newRecord) {
|
||||
const zoneName = db.getZoneName(provider, zoneId);
|
||||
return notify(
|
||||
`DNS Record Updated`,
|
||||
`[${provider}] ${zoneName}\n✎ ${oldRecord.type} ${oldRecord.name}\n ${oldRecord.content} → ${newRecord.content}`,
|
||||
);
|
||||
}
|
||||
|
||||
function recordDeleted(provider, zoneId, record) {
|
||||
const zoneName = db.getZoneName(provider, zoneId);
|
||||
return notify(
|
||||
`DNS Record Deleted`,
|
||||
`[${provider}] ${zoneName}\n− ${record.type} ${record.name} ${record.content}`,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { notify, recordAdded, recordUpdated, recordDeleted };
|
||||
@@ -0,0 +1,44 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const users = require('../users');
|
||||
const audit = require('../audit');
|
||||
const { signToken, requireAuth } = require('../auth');
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) return res.status(400).json({ error: 'Username and password are required' });
|
||||
|
||||
const user = users.findByUsername(username);
|
||||
if (!user || !users.verifyPassword(user, password)) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
const token = signToken(user);
|
||||
res.json({ token, user: { id: user.id, username: user.username } });
|
||||
});
|
||||
|
||||
// GET /api/auth/me — validate current token and return user info
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const user = users.findById(req.user.id);
|
||||
if (!user) return res.status(401).json({ error: 'User not found' });
|
||||
res.json({ id: user.id, username: user.username });
|
||||
});
|
||||
|
||||
// POST /api/auth/change-password — change own password
|
||||
router.post('/change-password', requireAuth, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Both current and new password are required' });
|
||||
if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
|
||||
|
||||
const user = users.findById(req.user.id);
|
||||
if (!users.verifyPassword(user, currentPassword)) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
users.updatePassword(req.user.id, newPassword);
|
||||
audit.logUser(req.user, 'update', { id: req.user.id, username: req.user.username });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,147 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireAuth } = require('../auth');
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
async function checkCloudflare() {
|
||||
const start = Date.now();
|
||||
const res = await fetch('https://api.cloudflare.com/client/v4/zones?per_page=1', {
|
||||
headers: { Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.errors?.[0]?.message ?? 'Auth failed');
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
async function checkLoopia() {
|
||||
const start = Date.now();
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?><methodCall><methodName>getDomains</methodName><params><param><value><string>${process.env.LOOPIA_USER}</string></value></param><param><value><string>${process.env.LOOPIA_PASSWORD}</string></value></param></params></methodCall>`;
|
||||
const res = await fetch('https://api.loopia.se/RPCSERV', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
|
||||
body,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (text.includes('AUTH_ERROR')) throw new Error('Authentication failed');
|
||||
if (text.includes('faultCode')) throw new Error('API returned a fault');
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
async function checkPihole() {
|
||||
const start = Date.now();
|
||||
const base = process.env.PIHOLE_URL.replace(/\/$/, '');
|
||||
const res = await fetch(`${base}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: process.env.PIHOLE_PASSWORD }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.session?.valid) throw new Error('Authentication failed');
|
||||
// Log out to clean up the session
|
||||
try {
|
||||
await fetch(`${base}/api/auth`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-FTL-SID': data.session.sid },
|
||||
});
|
||||
} catch { /* ignore logout errors */ }
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
async function checkAzure() {
|
||||
const start = Date.now();
|
||||
const { AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID } = process.env;
|
||||
const tokenRes = await fetch(
|
||||
`https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: AZURE_CLIENT_ID,
|
||||
client_secret: AZURE_CLIENT_SECRET,
|
||||
scope: 'https://management.azure.com/.default',
|
||||
}),
|
||||
}
|
||||
);
|
||||
const tokenData = await tokenRes.json();
|
||||
if (tokenData.error) throw new Error(tokenData.error_description ?? tokenData.error);
|
||||
|
||||
// Quick check: list DNS zones
|
||||
const zonesRes = await fetch(
|
||||
`https://management.azure.com/subscriptions/${AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Network/dnsZones?api-version=2018-05-01`,
|
||||
{ headers: { Authorization: `Bearer ${tokenData.access_token}` } }
|
||||
);
|
||||
const zonesData = await zonesRes.json();
|
||||
if (zonesData.error) throw new Error(zonesData.error.message ?? 'API error');
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
async function checkCpanel() {
|
||||
const https = require('https');
|
||||
const start = Date.now();
|
||||
const insecure = process.env.CPANEL_INSECURE === 'true';
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const url = new URL(`${process.env.CPANEL_URL.replace(/\/$/, '')}/execute/DNS/list_zones`);
|
||||
const lib = url.protocol === 'https:' ? https : require('http');
|
||||
const req = lib.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'GET',
|
||||
headers: { Authorization: `cpanel ${process.env.CPANEL_USERNAME}:${process.env.CPANEL_API_TOKEN}`, Accept: 'application/json' },
|
||||
rejectUnauthorized: !insecure,
|
||||
},
|
||||
res => {
|
||||
let body = '';
|
||||
res.on('data', c => { body += c; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
// status 0 with AUTH_ERROR or similar means bad credentials
|
||||
if (data.status === 0) reject(new Error(data.errors?.join(', ') ?? 'API error'));
|
||||
else resolve();
|
||||
} catch {
|
||||
if (body.trimStart().startsWith('<')) reject(new Error('Received HTML — check credentials or CPANEL_INSECURE setting'));
|
||||
else reject(new Error('Invalid response'));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
const CHECKS = {
|
||||
cloudflare: { name: 'Cloudflare', fn: checkCloudflare,
|
||||
configured: () => !!process.env.CLOUDFLARE_API_TOKEN },
|
||||
loopia: { name: 'Loopia', fn: checkLoopia,
|
||||
configured: () => !!(process.env.LOOPIA_USER && process.env.LOOPIA_PASSWORD) },
|
||||
pihole: { name: 'Pi-hole', fn: checkPihole,
|
||||
configured: () => !!(process.env.PIHOLE_URL && process.env.PIHOLE_PASSWORD) },
|
||||
azure: { name: 'Azure DNS', fn: checkAzure,
|
||||
configured: () => !!(process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET && process.env.AZURE_SUBSCRIPTION_ID) },
|
||||
cpanel: { name: 'cPanel', fn: checkCpanel,
|
||||
configured: () => !!(process.env.CPANEL_URL && process.env.CPANEL_USERNAME && process.env.CPANEL_API_TOKEN) },
|
||||
};
|
||||
|
||||
// GET /api/health/providers
|
||||
router.get('/providers', async (req, res) => {
|
||||
const results = await Promise.all(
|
||||
Object.entries(CHECKS).map(async ([id, { name, fn, configured }]) => {
|
||||
if (!configured()) return { id, name, status: 'unconfigured', latency: null, error: null };
|
||||
try {
|
||||
const latency = await fn();
|
||||
return { id, name, status: 'ok', latency, error: null };
|
||||
} catch (err) {
|
||||
return { id, name, status: 'error', latency: null, error: err.message };
|
||||
}
|
||||
})
|
||||
);
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,45 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ipam = require('../ipam');
|
||||
const audit = require('../audit');
|
||||
const { requireAuth } = require('../auth');
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json(ipam.getAll());
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const entry = ipam.create(req.body);
|
||||
audit.logIpam(req.user, 'add', entry);
|
||||
res.status(201).json(entry);
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const prev = ipam.getById(req.params.id);
|
||||
const entry = ipam.update(req.params.id, req.body);
|
||||
audit.logIpam(req.user, 'update', entry, prev);
|
||||
res.json(entry);
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const entry = ipam.getById(req.params.id);
|
||||
ipam.remove(req.params.id);
|
||||
audit.logIpam(req.user, 'delete', entry);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,98 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const cloudflare = require('../adapters/cloudflare');
|
||||
const loopia = require('../adapters/loopia');
|
||||
const pihole = require('../adapters/pihole');
|
||||
const azure = require('../adapters/azure');
|
||||
const cpanel = require('../adapters/cpanel');
|
||||
const db = require('../db');
|
||||
const notify = require('../notify');
|
||||
const audit = require('../audit');
|
||||
|
||||
const adapters = { cloudflare, loopia, pihole, azure, cpanel };
|
||||
|
||||
// GET /api/records/:provider/:zone
|
||||
// Returns cached records from the local DB. If the zone has never been
|
||||
// synced, returns an empty array with a `synced_at: null` hint.
|
||||
router.get('/:provider/:zone', (req, res) => {
|
||||
const { provider, zone } = req.params;
|
||||
const records = db.getRecords(provider, zone);
|
||||
const synced_at = db.getSyncedAt(provider, zone);
|
||||
res.json({ records, synced_at });
|
||||
});
|
||||
|
||||
// POST /api/sync/:provider/:zone
|
||||
// Fetches fresh records from the provider API, stores them in the DB,
|
||||
// and returns the updated record list.
|
||||
router.post('/sync/:provider/:zone', async (req, res) => {
|
||||
const { provider, zone } = req.params;
|
||||
const adapter = adapters[provider];
|
||||
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
|
||||
try {
|
||||
const records = await adapter.listRecords(zone);
|
||||
db.replaceZoneRecords(provider, zone, records, req.body?.zoneName);
|
||||
const synced_at = db.getSyncedAt(provider, zone);
|
||||
res.json({ records: db.getRecords(provider, zone), synced_at });
|
||||
} catch (err) {
|
||||
console.error(`[sync] ${provider}/${zone} error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/records/:provider/:zone
|
||||
router.post('/:provider/:zone', async (req, res) => {
|
||||
const { provider, zone } = req.params;
|
||||
const adapter = adapters[provider];
|
||||
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
|
||||
try {
|
||||
const result = await adapter.addRecord(zone, req.body);
|
||||
db.upsertRecord(provider, zone, result);
|
||||
notify.recordAdded(provider, zone, result);
|
||||
audit.log(req.user, 'add', provider, db.getZoneName(provider, zone), result);
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
console.error(`[records] ${provider} addRecord error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/records/:provider/:zone/:recordId
|
||||
router.put('/:provider/:zone/:recordId', async (req, res) => {
|
||||
const { provider, zone, recordId } = req.params;
|
||||
const adapter = adapters[provider];
|
||||
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
|
||||
try {
|
||||
const oldRecord = db.getRecords(provider, zone).find(r => r.id === recordId);
|
||||
const result = await adapter.updateRecord(zone, recordId, req.body);
|
||||
// For providers that delete+re-add (Loopia, Pi-hole), remove the old DB
|
||||
// entry by old ID and insert the new one returned by the adapter.
|
||||
db.deleteRecord(provider, zone, recordId);
|
||||
db.upsertRecord(provider, zone, { ...req.body, id: result.id ?? recordId });
|
||||
notify.recordUpdated(provider, zone, oldRecord ?? req.body, req.body);
|
||||
audit.log(req.user, 'update', provider, db.getZoneName(provider, zone), req.body, oldRecord);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error(`[records] ${provider} updateRecord error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/records/:provider/:zone/:recordId
|
||||
router.delete('/:provider/:zone/:recordId', async (req, res) => {
|
||||
const { provider, zone, recordId } = req.params;
|
||||
const adapter = adapters[provider];
|
||||
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
|
||||
try {
|
||||
const oldRecord = db.getRecords(provider, zone).find(r => r.id === recordId);
|
||||
await adapter.deleteRecord(zone, recordId);
|
||||
db.deleteRecord(provider, zone, recordId);
|
||||
if (oldRecord) notify.recordDeleted(provider, zone, oldRecord);
|
||||
audit.log(req.user, 'delete', provider, db.getZoneName(provider, zone), oldRecord ?? { id: recordId });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error(`[records] ${provider} deleteRecord error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,52 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const secrets = require('../secrets');
|
||||
const audit = require('../audit');
|
||||
const { requireAuth } = require('../auth');
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/secrets — all secrets with live status
|
||||
router.get('/', (req, res) => {
|
||||
res.json(secrets.getStatus());
|
||||
});
|
||||
|
||||
// POST /api/secrets
|
||||
router.post('/', (req, res) => {
|
||||
const { name, type, expires_at } = req.body;
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
if (!expires_at) return res.status(400).json({ error: 'Expiry date is required' });
|
||||
try {
|
||||
const secret = secrets.create(req.body);
|
||||
audit.logSecret(req.user, 'add', secret);
|
||||
res.status(201).json({ ...secret, daysLeft: null, status: 'ok' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/secrets/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const prev = secrets.getById(req.params.id);
|
||||
const secret = secrets.update(req.params.id, req.body);
|
||||
audit.logSecret(req.user, 'update', secret, prev);
|
||||
res.json(secret);
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/secrets/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const secret = secrets.getById(req.params.id);
|
||||
secrets.remove(req.params.id);
|
||||
audit.logSecret(req.user, 'delete', secret);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,67 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const settings = require('../settings');
|
||||
const { notify } = require('../notify');
|
||||
|
||||
// GET /api/settings
|
||||
router.get('/', (req, res) => {
|
||||
res.json(settings.get());
|
||||
});
|
||||
|
||||
// PUT /api/settings
|
||||
router.put('/', (req, res) => {
|
||||
try {
|
||||
const updated = settings.update(req.body);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/settings/test-notification
|
||||
router.post('/test-notification', async (req, res) => {
|
||||
// Use the payload from the request body so the user can test
|
||||
// before saving (the frontend sends the current form values)
|
||||
const { url, token, priority } = req.body;
|
||||
|
||||
if (!url || !token) {
|
||||
return res.status(400).json({ error: 'URL and token are required' });
|
||||
}
|
||||
|
||||
const base = url.replace(/\/$/, '');
|
||||
try {
|
||||
const response = await fetch(`${base}/message?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: '🦥 Sloth Manager — Test',
|
||||
message: 'Gotify notifications are working correctly.',
|
||||
priority: priority ?? 5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return res.status(502).json({ error: `Gotify returned ${response.status}: ${text}` });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: `Could not reach Gotify: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/settings/clear-cache
|
||||
router.post('/clear-cache', (req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cachePath = process.env.DB_PATH || path.join(__dirname, '../../dns-cache.json');
|
||||
fs.writeFileSync(cachePath, '{}', 'utf8');
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,58 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const users = require('../users');
|
||||
const audit = require('../audit');
|
||||
const { requireAuth } = require('../auth');
|
||||
|
||||
// All user management routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/users
|
||||
router.get('/', (req, res) => {
|
||||
res.json(users.getAll());
|
||||
});
|
||||
|
||||
// POST /api/users
|
||||
router.post('/', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) return res.status(400).json({ error: 'Username and password are required' });
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
try {
|
||||
const user = users.create(username, password);
|
||||
audit.logUser(req.user, 'add', user);
|
||||
res.status(201).json(user);
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/users/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
if (req.params.id === req.user.id) {
|
||||
return res.status(400).json({ error: 'You cannot delete your own account' });
|
||||
}
|
||||
try {
|
||||
const target = users.findById(req.params.id);
|
||||
users.remove(req.params.id);
|
||||
audit.logUser(req.user, 'delete', target);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/users/:id/password — admin reset of another user's password
|
||||
router.put('/:id/password', (req, res) => {
|
||||
const { newPassword } = req.body;
|
||||
if (!newPassword || newPassword.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
try {
|
||||
const target = users.findById(req.params.id);
|
||||
users.updatePassword(req.params.id, newPassword);
|
||||
audit.logUser(req.user, 'update', target);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const cloudflare = require('../adapters/cloudflare');
|
||||
const loopia = require('../adapters/loopia');
|
||||
const pihole = require('../adapters/pihole');
|
||||
const azure = require('../adapters/azure');
|
||||
const cpanel = require('../adapters/cpanel');
|
||||
|
||||
const adapters = { cloudflare, loopia, pihole, azure, cpanel };
|
||||
|
||||
// GET /api/zones/:provider
|
||||
router.get('/:provider', async (req, res) => {
|
||||
const adapter = adapters[req.params.provider];
|
||||
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
|
||||
try {
|
||||
const zones = await adapter.listZones();
|
||||
res.json(zones);
|
||||
} catch (err) {
|
||||
console.error(`[zones] ${req.params.provider} listZones error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,97 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SECRETS_PATH = process.env.SECRETS_PATH || path.join(__dirname, '..', 'secrets.json');
|
||||
|
||||
const SECRET_TYPES = ['api_token', 'ssl_certificate', 'password', 'generic'];
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(SECRETS_PATH, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function save(secrets) {
|
||||
fs.writeFileSync(SECRETS_PATH, JSON.stringify(secrets, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return load();
|
||||
}
|
||||
|
||||
function getById(id) {
|
||||
return load().find(s => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
function create(data) {
|
||||
const secrets = load();
|
||||
const secret = {
|
||||
id: Date.now().toString(),
|
||||
name: data.name,
|
||||
type: data.type || 'generic',
|
||||
description: data.description || '',
|
||||
expires_at: data.expires_at,
|
||||
warning_days: Number(data.warning_days) || 30,
|
||||
notes: data.notes || '',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
secrets.push(secret);
|
||||
save(secrets);
|
||||
return secret;
|
||||
}
|
||||
|
||||
function update(id, data) {
|
||||
const secrets = load();
|
||||
const idx = secrets.findIndex(s => s.id === id);
|
||||
if (idx === -1) throw new Error('Secret not found');
|
||||
secrets[idx] = {
|
||||
...secrets[idx],
|
||||
name: data.name ?? secrets[idx].name,
|
||||
type: data.type ?? secrets[idx].type,
|
||||
description: data.description ?? secrets[idx].description,
|
||||
expires_at: data.expires_at ?? secrets[idx].expires_at,
|
||||
warning_days: data.warning_days !== undefined ? Number(data.warning_days) : secrets[idx].warning_days,
|
||||
notes: data.notes ?? secrets[idx].notes,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
save(secrets);
|
||||
return secrets[idx];
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const secrets = load();
|
||||
const filtered = secrets.filter(s => s.id !== id);
|
||||
if (filtered.length === secrets.length) throw new Error('Secret not found');
|
||||
save(filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns secrets grouped by expiry status.
|
||||
* Status: 'expired' | 'warning' | 'ok'
|
||||
*/
|
||||
function getStatus() {
|
||||
const secrets = load();
|
||||
const now = new Date();
|
||||
|
||||
return secrets.map(s => {
|
||||
const expiry = new Date(s.expires_at);
|
||||
const daysLeft = Math.ceil((expiry - now) / (1000 * 60 * 60 * 24));
|
||||
let status;
|
||||
if (daysLeft < 0) status = 'expired';
|
||||
else if (daysLeft <= s.warning_days) status = 'warning';
|
||||
else status = 'ok';
|
||||
return { ...s, daysLeft, status };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns secrets that are expired or within their warning window.
|
||||
*/
|
||||
function getExpiring() {
|
||||
return getStatus().filter(s => s.status !== 'ok');
|
||||
}
|
||||
|
||||
module.exports = { SECRET_TYPES, getAll, getById, create, update, remove, getStatus, getExpiring };
|
||||
@@ -0,0 +1,57 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SETTINGS_PATH = process.env.SETTINGS_PATH || path.join(__dirname, '..', 'settings.json');
|
||||
|
||||
const DEFAULTS = {
|
||||
gotify: {
|
||||
enabled: false,
|
||||
url: '',
|
||||
token: '',
|
||||
priority: 5,
|
||||
},
|
||||
providerColors: {
|
||||
cloudflare: '#f6821f',
|
||||
loopia: '#2ecc71',
|
||||
pihole: '#96060c',
|
||||
azure: '#0078d4',
|
||||
cpanel: '#ff6c2c',
|
||||
},
|
||||
};
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
|
||||
// Deep merge with defaults so new keys are always present
|
||||
return {
|
||||
...DEFAULTS,
|
||||
...raw,
|
||||
gotify: { ...DEFAULTS.gotify, ...(raw.gotify ?? {}) },
|
||||
providerColors: { ...DEFAULTS.providerColors, ...(raw.providerColors ?? {}) },
|
||||
};
|
||||
} catch {
|
||||
return structuredClone(DEFAULTS);
|
||||
}
|
||||
}
|
||||
|
||||
function save(settings) {
|
||||
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function get() {
|
||||
return load();
|
||||
}
|
||||
|
||||
function update(partial) {
|
||||
const current = load();
|
||||
const merged = {
|
||||
...current,
|
||||
...partial,
|
||||
gotify: { ...current.gotify, ...(partial.gotify ?? {}) },
|
||||
providerColors: { ...current.providerColors, ...(partial.providerColors ?? {}) },
|
||||
};
|
||||
save(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
module.exports = { get, update };
|
||||
@@ -0,0 +1,84 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const USERS_PATH = process.env.USERS_PATH || path.join(__dirname, '..', 'users.json');
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(USERS_PATH, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function save(users) {
|
||||
fs.writeFileSync(USERS_PATH, JSON.stringify(users, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* On first run, create a default admin user if no users exist.
|
||||
* Prints a warning to remind the user to change the password.
|
||||
*/
|
||||
function ensureDefaultAdmin() {
|
||||
const users = load();
|
||||
if (users.length === 0) {
|
||||
const hash = bcrypt.hashSync('admin', 10);
|
||||
save([{ id: '1', username: 'admin', passwordHash: hash, createdAt: new Date().toISOString() }]);
|
||||
console.warn('⚠️ No users found — created default admin account.');
|
||||
console.warn('⚠️ Username: admin Password: admin');
|
||||
console.warn('⚠️ Change this password immediately in Settings → Users.');
|
||||
}
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return load().map(({ passwordHash, ...u }) => u); // strip hash from output
|
||||
}
|
||||
|
||||
function findByUsername(username) {
|
||||
return load().find(u => u.username.toLowerCase() === username.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
function findById(id) {
|
||||
return load().find(u => u.id === id) ?? null;
|
||||
}
|
||||
|
||||
function create(username, password) {
|
||||
const users = load();
|
||||
if (users.find(u => u.username.toLowerCase() === username.toLowerCase())) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const user = {
|
||||
id: Date.now().toString(),
|
||||
username,
|
||||
passwordHash: hash,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
users.push(user);
|
||||
save(users);
|
||||
const { passwordHash, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
|
||||
function updatePassword(id, newPassword) {
|
||||
const users = load();
|
||||
const idx = users.findIndex(u => u.id === id);
|
||||
if (idx === -1) throw new Error('User not found');
|
||||
users[idx].passwordHash = bcrypt.hashSync(newPassword, 10);
|
||||
save(users);
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const users = load();
|
||||
if (users.length === 1) throw new Error('Cannot delete the last user');
|
||||
const filtered = users.filter(u => u.id !== id);
|
||||
if (filtered.length === users.length) throw new Error('User not found');
|
||||
save(filtered);
|
||||
}
|
||||
|
||||
function verifyPassword(user, password) {
|
||||
return bcrypt.compareSync(password, user.passwordHash);
|
||||
}
|
||||
|
||||
module.exports = { ensureDefaultAdmin, getAll, findByUsername, findById, create, updatePassword, remove, verifyPassword };
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: sloth-backend
|
||||
restart: unless-stopped
|
||||
env_file: ./backend/.env
|
||||
volumes:
|
||||
- sloth-data:/data
|
||||
networks:
|
||||
- sloth-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/providers"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: sloth-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
networks:
|
||||
- sloth-net
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
sloth-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
sloth-net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,3 @@
|
||||
HOST=127.0.0.1
|
||||
DANGEROUSLY_DISABLE_HOST_CHECK=true
|
||||
REACT_APP_API_URL=http://localhost:3001
|
||||
@@ -0,0 +1,22 @@
|
||||
# ── Stage 1: build the React app ─────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY public/ ./public/
|
||||
COPY src/ ./src/
|
||||
|
||||
# API calls are proxied by nginx so no REACT_APP_API_URL needed
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: serve with nginx ─────────────────────────────────────────────────
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API calls to the backend container
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# React app — all other routes serve index.html (client-side routing)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|svg|ico|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Generated
+17521
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "dns-manager-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sloth Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getProviders, getZones, getRecords, syncRecords, addRecord, updateRecord, deleteRecord, getMe, setToken } from './api/dns';
|
||||
import RecordsTable from './components/RecordsTable';
|
||||
import AddRecordForm from './components/AddRecordForm';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import SettingsPage from './components/SettingsPage';
|
||||
import ProviderOverview from './components/ProviderOverview';
|
||||
import LoginPage from './components/LoginPage';
|
||||
import ProfilePage from './components/ProfilePage';
|
||||
import AuditPage from './components/AuditPage';
|
||||
import SecretsPage from './components/SecretsPage';
|
||||
import IpamPage from './components/IpamPage';
|
||||
import DomainsPage from './components/DomainsPage';
|
||||
import { useProviderColors, providerBadgeStyle } from './context/ProviderColors';
|
||||
import { useTheme } from './context/Theme';
|
||||
import ConfirmDialog from './components/ConfirmDialog';
|
||||
import './App.css';
|
||||
|
||||
function formatSyncedAt(iso) {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
const date = d.toLocaleDateString('sv-SE'); // YYYY-MM-DD
|
||||
const time = d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
// ─── Main app (shown when authenticated) ─────────────────────────────────────
|
||||
|
||||
function AppShell({ currentUser, onLogout }) {
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [selectedProvider, setSelectedProvider] = useState(null);
|
||||
const [zones, setZones] = useState([]);
|
||||
const [selectedZone, setSelectedZone] = useState(null);
|
||||
const [records, setRecords] = useState([]);
|
||||
const [syncedAt, setSyncedAt] = useState(null);
|
||||
const [loadingZones, setLoadingZones] = useState(false);
|
||||
const [loadingRecords, setLoadingRecords] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [view, setView] = useState('dashboard');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState(null);
|
||||
const { colors: providerColors } = useProviderColors();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getProviders()
|
||||
.then(setProviders)
|
||||
.catch(e => setError(e.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProvider) return;
|
||||
setZones([]);
|
||||
setSelectedZone(null);
|
||||
setRecords([]);
|
||||
setSyncedAt(null);
|
||||
setError('');
|
||||
setLoadingZones(true);
|
||||
getZones(selectedProvider)
|
||||
.then(setZones)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoadingZones(false));
|
||||
}, [selectedProvider]);
|
||||
|
||||
const loadRecords = useCallback((provider, zone) => {
|
||||
setLoadingRecords(true);
|
||||
setError('');
|
||||
getRecords(provider, zone.id)
|
||||
.then(({ records, synced_at }) => {
|
||||
setRecords(records);
|
||||
setSyncedAt(synced_at);
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoadingRecords(false));
|
||||
}, []);
|
||||
|
||||
function selectZone(zone) {
|
||||
setSelectedZone(zone);
|
||||
setFilter('');
|
||||
loadRecords(selectedProvider, zone);
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
setSyncing(true);
|
||||
setError('');
|
||||
try {
|
||||
const { records, synced_at } = await syncRecords(selectedProvider, selectedZone.id, selectedZone.name);
|
||||
setRecords(records);
|
||||
setSyncedAt(synced_at);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openAddForm() { setEditingRecord(null); setShowForm(true); }
|
||||
function openEditForm(record) { setEditingRecord(record); setShowForm(true); }
|
||||
function closeForm() { setShowForm(false); setEditingRecord(null); }
|
||||
|
||||
async function handleFormSubmit(formData) {
|
||||
if (editingRecord) {
|
||||
const result = await updateRecord(selectedProvider, selectedZone.id, editingRecord.id, formData);
|
||||
setRecords(rs => rs.map(r => r.id === editingRecord.id ? { ...formData, id: result.id ?? editingRecord.id } : r));
|
||||
} else {
|
||||
const result = await addRecord(selectedProvider, selectedZone.id, formData);
|
||||
setRecords(rs => [...rs, { ...formData, id: result.id }]);
|
||||
}
|
||||
closeForm();
|
||||
}
|
||||
|
||||
function handleDeleteRecord(record) {
|
||||
setConfirmDelete(record);
|
||||
}
|
||||
|
||||
async function confirmDeleteRecord() {
|
||||
const record = confirmDelete;
|
||||
setConfirmDelete(null);
|
||||
setDeletingId(record.id);
|
||||
try {
|
||||
await deleteRecord(selectedProvider, selectedZone.id, record.id);
|
||||
setRecords(rs => rs.filter(r => r.id !== record.id));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRecords = records.filter(r => {
|
||||
const q = filter.toLowerCase();
|
||||
return !q || r.name.toLowerCase().includes(q) || r.content.toLowerCase().includes(q) || r.type.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>🦥 Sloth Manager</h1>
|
||||
<div className="header-user">
|
||||
<button className="btn-theme" onClick={toggleTheme} title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</button>
|
||||
<span className="header-username">{currentUser.username}</span>
|
||||
<button className="btn-logout" onClick={onLogout}>Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="layout">
|
||||
<aside className="sidebar">
|
||||
{/* Overview */}
|
||||
<div className="sidebar-section">
|
||||
<button
|
||||
className={`sidebar-item ${view === 'dashboard' && !selectedProvider ? 'active' : ''}`}
|
||||
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('dashboard'); }}
|
||||
>
|
||||
🏠 Overview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* DNS */}
|
||||
<div className="sidebar-section">
|
||||
<h2>DNS</h2>
|
||||
<button
|
||||
className={`sidebar-item ${view === 'domains' && !selectedProvider ? 'active' : ''}`}
|
||||
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('domains'); }}
|
||||
>
|
||||
🌍 All Domains
|
||||
</button>
|
||||
{providers.length === 0 && <p className="hint">No providers configured.<br />Set up your .env file.</p>}
|
||||
{providers.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={`sidebar-item ${selectedProvider === p.id ? 'active' : ''}`}
|
||||
onClick={() => { setSelectedProvider(p.id); setView('dashboard'); }}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedProvider && (
|
||||
<div className="sidebar-section">
|
||||
<h2>Zones / Domains</h2>
|
||||
{loadingZones && <p className="hint">Loading…</p>}
|
||||
{zones.map(z => (
|
||||
<button
|
||||
key={z.id}
|
||||
className={`sidebar-item ${selectedZone?.id === z.id ? 'active' : ''}`}
|
||||
onClick={() => selectZone(z)}
|
||||
>
|
||||
{z.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Tools */}
|
||||
<div className="sidebar-section">
|
||||
<h2>Tools</h2>
|
||||
<button
|
||||
className={`sidebar-item ${view === 'secrets' ? 'active' : ''}`}
|
||||
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('secrets'); }}
|
||||
>
|
||||
🔑 Secrets
|
||||
</button>
|
||||
<button
|
||||
className={`sidebar-item ${view === 'ipam' ? 'active' : ''}`}
|
||||
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('ipam'); }}
|
||||
>
|
||||
🖥️ IP Addresses
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* System */}
|
||||
<div className="sidebar-section">
|
||||
<h2>System</h2>
|
||||
<button
|
||||
className={`sidebar-item ${view === 'audit' ? 'active' : ''}`}
|
||||
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('audit'); }}
|
||||
>
|
||||
📋 Audit Log
|
||||
</button>
|
||||
<button
|
||||
className={`sidebar-item ${view === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('settings'); }}
|
||||
>
|
||||
⚙️ Settings
|
||||
</button>
|
||||
<button
|
||||
className={`sidebar-item ${view === 'profile' ? 'active' : ''}`}
|
||||
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('profile'); }}
|
||||
>
|
||||
👤 My Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
By <a href="https://bobbantech.com" target="_blank" rel="noreferrer">bobbantech</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<strong>Error:</strong> {error}
|
||||
<button className="dismiss" onClick={() => setError('')}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedProvider && view === 'settings' && <SettingsPage currentUser={currentUser} />}
|
||||
{!selectedProvider && view === 'profile' && <ProfilePage currentUser={currentUser} />}
|
||||
{!selectedProvider && view === 'domains' && (
|
||||
<DomainsPage onNavigate={(provider, zoneId, zoneName) => {
|
||||
setSelectedProvider(provider);
|
||||
setView('dashboard');
|
||||
// wait for zones to load then select
|
||||
setSelectedZone({ id: zoneId, name: zoneName });
|
||||
loadRecords(provider, { id: zoneId, name: zoneName });
|
||||
}} />
|
||||
)}
|
||||
{!selectedProvider && view === 'secrets' && <SecretsPage />}
|
||||
{!selectedProvider && view === 'ipam' && <IpamPage />}
|
||||
{!selectedProvider && view === 'audit' && <AuditPage />}
|
||||
{!selectedProvider && view === 'dashboard' && <Dashboard />}
|
||||
|
||||
{selectedProvider && !selectedZone && (
|
||||
<ProviderOverview
|
||||
provider={selectedProvider}
|
||||
providerMeta={providers.find(p => p.id === selectedProvider)}
|
||||
zones={zones}
|
||||
onSelectZone={selectZone}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedZone && (
|
||||
<>
|
||||
<div className="records-header">
|
||||
<div>
|
||||
<h2>{selectedZone.name}</h2>
|
||||
<span className="badge" style={providerBadgeStyle(providerColors, selectedProvider)}>{selectedProvider}</span>
|
||||
<span className="record-count">{records.length} records</span>
|
||||
{syncedAt
|
||||
? <span className="synced-at">Last synced: {formatSyncedAt(syncedAt)}</span>
|
||||
: <span className="synced-at never">Never synced — press Sync to load records</span>
|
||||
}
|
||||
</div>
|
||||
<div className="records-actions">
|
||||
<input
|
||||
className="filter-input"
|
||||
placeholder="Filter records…"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
<button className="btn-primary" onClick={openAddForm}>+ Add Record</button>
|
||||
<button className="btn-sync" onClick={handleSync} disabled={syncing} title="Fetch latest records from provider">
|
||||
{syncing ? 'Syncing…' : '⟳ Sync'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingRecords
|
||||
? <p className="hint">Loading…</p>
|
||||
: !syncedAt
|
||||
? <div className="empty-state"><p>Press <strong>Sync</strong> to fetch records from {selectedProvider}.</p></div>
|
||||
: <RecordsTable records={filteredRecords} onDelete={handleDeleteRecord} onEdit={openEditForm} deleting={deletingId} zoneName={selectedZone?.name} />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete DNS Record"
|
||||
message={`Delete ${confirmDelete.type} record "${confirmDelete.name}" → ${confirmDelete.content}?`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={confirmDeleteRecord}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<AddRecordForm
|
||||
provider={selectedProvider}
|
||||
zone={selectedZone?.name}
|
||||
existing={editingRecord}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Auth gate (always renders, no hook ordering issues) ──────────────────────
|
||||
|
||||
export default function App() {
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
.then(user => { setCurrentUser(user); setAuthChecked(true); })
|
||||
.catch(() => setAuthChecked(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setCurrentUser(null);
|
||||
window.addEventListener('auth:logout', handler);
|
||||
return () => window.removeEventListener('auth:logout', handler);
|
||||
}, []);
|
||||
|
||||
function handleLogin(user) { setCurrentUser(user); }
|
||||
|
||||
function handleLogout() {
|
||||
setToken(null);
|
||||
setCurrentUser(null);
|
||||
}
|
||||
|
||||
if (!authChecked) return null;
|
||||
if (!currentUser) return <LoginPage onLogin={handleLogin} />;
|
||||
return <AppShell currentUser={currentUser} onLogout={handleLogout} />;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
const BASE = (process.env.REACT_APP_API_URL || 'http://localhost:3001') + '/api';
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem('dns_token');
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
if (token) localStorage.setItem('dns_token', token);
|
||||
else localStorage.removeItem('dns_token');
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function handleResponse(res) {
|
||||
if (res.status === 401) {
|
||||
setToken(null);
|
||||
window.dispatchEvent(new Event('auth:logout'));
|
||||
throw new Error('Session expired — please log in again');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function login(username, password) {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return handleResponse(res);
|
||||
}
|
||||
|
||||
export async function getMe() {
|
||||
return handleResponse(await fetch(`${BASE}/auth/me`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function changePassword(currentPassword, newPassword) {
|
||||
return handleResponse(await fetch(`${BASE}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getUsers() {
|
||||
return handleResponse(await fetch(`${BASE}/users`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function createUser(username, password) {
|
||||
return handleResponse(await fetch(`${BASE}/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteUser(id) {
|
||||
return handleResponse(await fetch(`${BASE}/users/${id}`, {
|
||||
method: 'DELETE', headers: authHeaders(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resetUserPassword(id, newPassword) {
|
||||
return handleResponse(await fetch(`${BASE}/users/${id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ newPassword }),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── IPAM ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getIpam() {
|
||||
return handleResponse(await fetch(`${BASE}/ipam`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function createIpEntry(data) {
|
||||
return handleResponse(await fetch(`${BASE}/ipam`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateIpEntry(id, data) {
|
||||
return handleResponse(await fetch(`${BASE}/ipam/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteIpEntry(id) {
|
||||
return handleResponse(await fetch(`${BASE}/ipam/${id}`, {
|
||||
method: 'DELETE', headers: authHeaders(),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Secrets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSecrets() {
|
||||
return handleResponse(await fetch(`${BASE}/secrets`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function createSecret(data) {
|
||||
return handleResponse(await fetch(`${BASE}/secrets`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateSecret(id, data) {
|
||||
return handleResponse(await fetch(`${BASE}/secrets/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteSecret(id) {
|
||||
return handleResponse(await fetch(`${BASE}/secrets/${id}`, {
|
||||
method: 'DELETE', headers: authHeaders(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAuditLog({ limit, offset, user, action, provider, category } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (limit) qs.set('limit', limit);
|
||||
if (offset) qs.set('offset', offset);
|
||||
if (user) qs.set('user', user);
|
||||
if (action) qs.set('action', action);
|
||||
if (provider) qs.set('provider', provider);
|
||||
if (category) qs.set('category', category);
|
||||
return handleResponse(await fetch(`${BASE}/audit?${qs}`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getProviderHealth() {
|
||||
return handleResponse(await fetch(`${BASE}/health/providers`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getProviders() {
|
||||
return handleResponse(await fetch(`${BASE}/providers`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getStats() {
|
||||
return handleResponse(await fetch(`${BASE}/stats`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getAllDomains() {
|
||||
return handleResponse(await fetch(`${BASE}/domains`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getSettings() {
|
||||
return handleResponse(await fetch(`${BASE}/settings`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function saveSettings(settings) {
|
||||
return handleResponse(await fetch(`${BASE}/settings`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(settings),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
return handleResponse(await fetch(`${BASE}/settings/clear-cache`, { method: 'POST', headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function testNotification(gotify) {
|
||||
return handleResponse(await fetch(`${BASE}/settings/test-notification`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(gotify),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSyncStatus(provider) {
|
||||
return handleResponse(await fetch(`${BASE}/sync-status/${provider}`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getZones(provider) {
|
||||
return handleResponse(await fetch(`${BASE}/zones/${provider}`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getRecords(provider, zoneId) {
|
||||
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function syncRecords(provider, zoneId, zoneName) {
|
||||
return handleResponse(await fetch(`${BASE}/records/sync/${provider}/${encodeURIComponent(zoneId)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ zoneName }),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addRecord(provider, zoneId, record) {
|
||||
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(record),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateRecord(provider, zoneId, recordId, record) {
|
||||
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}/${encodeURIComponent(recordId)}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(record),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteRecord(provider, zoneId, recordId) {
|
||||
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}/${encodeURIComponent(recordId)}`, {
|
||||
method: 'DELETE', headers: authHeaders(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const ALL_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR'];
|
||||
const PIHOLE_RECORD_TYPES = ['A', 'AAAA', 'CNAME'];
|
||||
|
||||
// Pass `existing` prop to enter edit mode.
|
||||
export default function AddRecordForm({ provider, zone, onSubmit, onCancel, existing }) {
|
||||
const editMode = Boolean(existing);
|
||||
const RECORD_TYPES = provider === 'pihole' ? PIHOLE_RECORD_TYPES : ALL_RECORD_TYPES;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
type: existing?.type || 'A',
|
||||
name: existing?.name || '',
|
||||
content: existing?.content || '',
|
||||
ttl: existing?.ttl ?? 3600,
|
||||
priority: existing?.priority ?? '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const needsPriority = ['MX', 'SRV'].includes(form.type);
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
setForm(f => ({ ...f, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit(form);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h3>{editMode ? 'Edit DNS Record' : 'Add DNS Record'}</h3>
|
||||
<p className="modal-subtitle">
|
||||
<span className="badge">{provider}</span> {zone}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="record-form">
|
||||
<div className="form-row">
|
||||
<label>Type
|
||||
<select name="type" value={form.type} onChange={handleChange} disabled={editMode}>
|
||||
{RECORD_TYPES.map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>TTL (seconds)
|
||||
<input name="ttl" type="number" value={form.ttl} onChange={handleChange} min={1} required />
|
||||
</label>
|
||||
{needsPriority && (
|
||||
<label>Priority
|
||||
<input name="priority" type="number" value={form.priority} onChange={handleChange} min={0} required />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<label>Name / Host
|
||||
<input name="name" value={form.name} onChange={handleChange} placeholder="@ or subdomain" required />
|
||||
</label>
|
||||
<label>Content / Value
|
||||
<input name="content" value={form.content} onChange={handleChange} placeholder="e.g. 1.2.3.4" required />
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onCancel} disabled={loading}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? (editMode ? 'Saving…' : 'Adding…') : (editMode ? 'Save Changes' : 'Add Record')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getAuditLog } from '../api/dns';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
|
||||
const ACTION_LABELS = {
|
||||
add: { label: 'Added', cls: 'audit-add' },
|
||||
update: { label: 'Updated', cls: 'audit-update' },
|
||||
delete: { label: 'Deleted', cls: 'audit-delete' },
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
dns: { label: 'DNS', cls: 'audit-cat-dns' },
|
||||
secret: { label: 'Secret', cls: 'audit-cat-secret' },
|
||||
ipam: { label: 'IP Address', cls: 'audit-cat-ipam' },
|
||||
user: { label: 'User', cls: 'audit-cat-user' },
|
||||
};
|
||||
|
||||
const SECRET_TYPE_LABELS = {
|
||||
api_token: 'API Token',
|
||||
ssl_certificate: 'SSL Certificate',
|
||||
password: 'Password',
|
||||
generic: 'Generic',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
function EntryDetail({ entry }) {
|
||||
const { category, action, record, prev, target } = entry;
|
||||
|
||||
if (category === 'dns') {
|
||||
if (!record?.name) return null;
|
||||
if (prev && action === 'update') {
|
||||
const contentChanged = prev.content !== record.content;
|
||||
return (
|
||||
<span className="audit-detail">
|
||||
{record.type} {record.name}
|
||||
{contentChanged && <> <s style={{ opacity: 0.5 }}>{prev.content}</s> → {record.content}</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="audit-detail">{record.type} {record.name} {record.content}</span>;
|
||||
}
|
||||
|
||||
if (category === 'secret') {
|
||||
if (!target) return null;
|
||||
const typeLabel = SECRET_TYPE_LABELS[target.type] ?? target.type;
|
||||
if (prev && action === 'update') {
|
||||
return <span className="audit-detail">{typeLabel} — {prev.name}{prev.name !== target.name ? ` → ${target.name}` : ''}</span>;
|
||||
}
|
||||
return <span className="audit-detail">{typeLabel} — {target.name}{target.expires ? ` (expires ${target.expires?.slice(0, 10)})` : ''}</span>;
|
||||
}
|
||||
|
||||
if (category === 'user') {
|
||||
if (!entry.target) return null;
|
||||
const isSelf = entry.user?.id === entry.target?.id;
|
||||
if (action === 'update') return <span className="audit-detail">{entry.target.username}{isSelf ? ' (own password)' : ' (password reset)'}</span>;
|
||||
return <span className="audit-detail">{entry.target.username}</span>;
|
||||
}
|
||||
|
||||
if (category === 'ipam') {
|
||||
if (!target) return null;
|
||||
if (prev && action === 'update') {
|
||||
const addrChanged = prev.address !== target.address;
|
||||
const labelChanged = prev.label !== target.label;
|
||||
return (
|
||||
<span className="audit-detail">
|
||||
{target.address}
|
||||
{labelChanged && <> ({prev.label} → {target.label})</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="audit-detail">{target.address}{target.label ? ` — ${target.label}` : ''}{target.vendor ? ` (${target.vendor})` : ''}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [filterUser, setFilterUser] = useState('');
|
||||
const [filterAction, setFilterAction] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [filterProvider, setFilterProvider] = useState('');
|
||||
|
||||
const load = useCallback((pg = 0) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
getAuditLog({
|
||||
limit: PAGE_SIZE,
|
||||
offset: pg * PAGE_SIZE,
|
||||
user: filterUser || undefined,
|
||||
action: filterAction || undefined,
|
||||
category: filterCategory || undefined,
|
||||
provider: filterProvider || undefined,
|
||||
})
|
||||
.then(({ entries, total }) => { setEntries(entries); setTotal(total); })
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filterUser, filterAction, filterCategory, filterProvider]);
|
||||
|
||||
useEffect(() => { setPage(0); load(0); }, [load]);
|
||||
|
||||
function goToPage(pg) { setPage(pg); load(pg); }
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
function handleExport() {
|
||||
exportCsv(
|
||||
entries.map(e => ({
|
||||
timestamp: e.timestamp,
|
||||
user: e.user?.username ?? '',
|
||||
category: e.category,
|
||||
action: e.action,
|
||||
provider: e.provider ?? '',
|
||||
zone: e.zone ?? '',
|
||||
detail: e.category === 'dns'
|
||||
? `${e.record?.type ?? ''} ${e.record?.name ?? ''} ${e.record?.content ?? ''}`
|
||||
: e.category === 'secret'
|
||||
? `${e.target?.type ?? ''} — ${e.target?.name ?? ''}`
|
||||
: `${e.target?.address ?? ''} ${e.target?.label ?? ''}`,
|
||||
})),
|
||||
['timestamp','user','category','action','provider','zone','detail'],
|
||||
{ timestamp:'Time', user:'User', category:'Category', action:'Action', provider:'Provider', zone:'Zone', detail:'Detail' },
|
||||
'audit-log.csv'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<div>
|
||||
<h2>Audit Log</h2>
|
||||
<p className="dashboard-hint">All changes made through Sloth Manager. Newest first.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audit-filters">
|
||||
<input className="filter-input" placeholder="Filter by user…" value={filterUser} onChange={e => setFilterUser(e.target.value)} />
|
||||
<select className="filter-input" value={filterCategory} onChange={e => setFilterCategory(e.target.value)}>
|
||||
<option value="">All categories</option>
|
||||
<option value="dns">DNS</option>
|
||||
<option value="secret">Secrets</option>
|
||||
<option value="ipam">IP Addresses</option>
|
||||
<option value="user">Users</option>
|
||||
</select>
|
||||
<select className="filter-input" value={filterAction} onChange={e => setFilterAction(e.target.value)}>
|
||||
<option value="">All actions</option>
|
||||
<option value="add">Added</option>
|
||||
<option value="update">Updated</option>
|
||||
<option value="delete">Deleted</option>
|
||||
</select>
|
||||
<input className="filter-input" placeholder="Filter by provider…" value={filterProvider} onChange={e => setFilterProvider(e.target.value)} style={{ display: filterCategory && filterCategory !== 'dns' ? 'none' : undefined }} />
|
||||
<span className="record-count">{total} entries</span>
|
||||
<button className="btn-export" onClick={handleExport}>⬇ Export CSV</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p className="hint">Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="empty-state"><p>No audit log entries yet.</p></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>User</th>
|
||||
<th>Category</th>
|
||||
<th>Action</th>
|
||||
<th>Detail</th>
|
||||
<th>Provider / Zone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(e => {
|
||||
const action = ACTION_LABELS[e.action] ?? { label: e.action, cls: '' };
|
||||
const category = CATEGORY_LABELS[e.category] ?? { label: e.category, cls: '' };
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(e.timestamp)}</td>
|
||||
<td><span className="audit-user">{e.user?.username ?? '—'}</span></td>
|
||||
<td><span className={`audit-badge ${category.cls}`}>{category.label}</span></td>
|
||||
<td><span className={`audit-badge ${action.cls}`}>{action.label}</span></td>
|
||||
<td><EntryDetail entry={e} /></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{e.provider && <span style={{ textTransform: 'capitalize' }}>{e.provider}</span>}
|
||||
{e.zone && <span> / {e.zone}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="audit-pagination">
|
||||
<button className="btn-secondary" onClick={() => goToPage(page - 1)} disabled={page === 0}>← Prev</button>
|
||||
<span className="record-count">Page {page + 1} of {totalPages}</span>
|
||||
<button className="btn-secondary" onClick={() => goToPage(page + 1)} disabled={page >= totalPages - 1}>Next →</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Reusable confirmation dialog.
|
||||
*
|
||||
* Props:
|
||||
* title - dialog heading
|
||||
* message - body text
|
||||
* confirmLabel - text for the confirm button (default "Confirm")
|
||||
* danger - if true, confirm button uses the danger style
|
||||
* onConfirm - called when the user confirms
|
||||
* onCancel - called when the user cancels or clicks outside
|
||||
*/
|
||||
export default function ConfirmDialog({
|
||||
title = 'Are you sure?',
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal confirm-dialog" onClick={e => e.stopPropagation()}>
|
||||
<h3>{title}</h3>
|
||||
{message && <p className="confirm-message" style={{ whiteSpace: 'pre-line' }}>{message}</p>}
|
||||
<div className="form-actions" style={{ marginTop: 20 }}>
|
||||
<button className="btn-secondary" onClick={onCancel}>Cancel</button>
|
||||
<button
|
||||
className={danger ? 'btn-danger' : 'btn-primary'}
|
||||
onClick={onConfirm}
|
||||
autoFocus
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { getStats, getSecrets } from '../api/dns';
|
||||
|
||||
// ─── DNS chart colours ────────────────────────────────────────────────────────
|
||||
|
||||
const DNS_TYPE_COLORS = {
|
||||
A: '#60a5fa',
|
||||
AAAA: '#93c5fd',
|
||||
CNAME: '#c084fc',
|
||||
MX: '#4ade80',
|
||||
TXT: '#fb923c',
|
||||
NS: '#94a3b8',
|
||||
SRV: '#a3e635',
|
||||
CAA: '#f472b6',
|
||||
PTR: '#facc15',
|
||||
};
|
||||
|
||||
const SECRET_TYPE_COLORS = {
|
||||
api_token: '#6366f1',
|
||||
ssl_certificate: '#22d3ee',
|
||||
password: '#f472b6',
|
||||
generic: '#94a3b8',
|
||||
};
|
||||
|
||||
const SECRET_TYPE_LABELS = {
|
||||
api_token: 'API Token',
|
||||
ssl_certificate: 'SSL Certificate',
|
||||
password: 'Password',
|
||||
generic: 'Generic',
|
||||
};
|
||||
|
||||
function dnsTypeColor(type) { return DNS_TYPE_COLORS[type] ?? '#6366f1'; }
|
||||
function secretTypeColor(type) { return SECRET_TYPE_COLORS[type] ?? '#6366f1'; }
|
||||
|
||||
// ─── Shared components ────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, sub, accent }) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={accent ? { color: accent } : {}}>{value}</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
{sub && <div className="stat-sub">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title, hint }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 16, marginTop: 8 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{title}</h3>
|
||||
{hint && <p className="dashboard-hint" style={{ marginBottom: 0 }}>{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DnsTooltip = ({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const { type, count } = payload[0].payload;
|
||||
return (
|
||||
<div className="chart-tooltip">
|
||||
<span className="chart-tooltip-type">{type}</span>
|
||||
<span>{count} record{count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SecretTooltip = ({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const { type, count } = payload[0].payload;
|
||||
return (
|
||||
<div className="chart-tooltip">
|
||||
<span className="chart-tooltip-type">{SECRET_TYPE_LABELS[type] ?? type}</span>
|
||||
<span>{count} secret{count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [secrets, setSecrets] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getStats().then(setStats).catch(e => setError(e.message));
|
||||
getSecrets().then(setSecrets).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// DNS stats
|
||||
const totalRecords = stats?.recordTypes.reduce((s, r) => s + r.count, 0) ?? 0;
|
||||
|
||||
// Secrets stats
|
||||
const totalSecrets = secrets.length;
|
||||
const expiredCount = secrets.filter(s => s.status === 'expired').length;
|
||||
const warningCount = secrets.filter(s => s.status === 'warning').length;
|
||||
|
||||
const secretsByType = Object.entries(
|
||||
secrets.reduce((acc, s) => {
|
||||
acc[s.type] = (acc[s.type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([type, count]) => ({ type, count }));
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h2>Overview</h2>
|
||||
<p className="dashboard-hint">DNS statistics are based on locally cached records. Sync a zone to update them.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="error" style={{ marginBottom: 16 }}>{error}</p>}
|
||||
|
||||
<div className="dashboard-grid">
|
||||
{/* ── DNS Column ── */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="DNS"
|
||||
hint={stats ? `${stats.totalZones} zone${stats.totalZones !== 1 ? 's' : ''} across ${Object.keys(stats.perProvider).length} provider${Object.keys(stats.perProvider).length !== 1 ? 's' : ''}` : ''}
|
||||
/>
|
||||
{stats && (
|
||||
<>
|
||||
<div className="stat-cards">
|
||||
<StatCard
|
||||
label="Domains"
|
||||
value={stats.totalZones}
|
||||
sub={Object.entries(stats.perProvider).map(([p, v]) => `${v.zones} on ${p}`).join(' · ')}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Records"
|
||||
value={totalRecords}
|
||||
sub={`${stats.recordTypes.length} type${stats.recordTypes.length !== 1 ? 's' : ''}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="With MX"
|
||||
value={stats.zonesWithMx}
|
||||
sub={stats.totalZones > 0 ? `${Math.round((stats.zonesWithMx / stats.totalZones) * 100)}%` : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stats.recordTypes.length > 0 ? (
|
||||
<div className="chart-section">
|
||||
<h3>Record Types</h3>
|
||||
<div className="chart-wrapper">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={stats.recordTypes}
|
||||
dataKey="count"
|
||||
nameKey="type"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={85}
|
||||
innerRadius={42}
|
||||
paddingAngle={2}
|
||||
label={({ type, percent }) => percent > 0.05 ? `${type} ${(percent * 100).toFixed(0)}%` : ''}
|
||||
labelLine={false}
|
||||
>
|
||||
{stats.recordTypes.map(entry => (
|
||||
<Cell key={entry.type} fill={dnsTypeColor(entry.type)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<DnsTooltip />} />
|
||||
<Legend formatter={v => <span style={{ color: 'var(--text)', fontSize: 12 }}>{v}</span>} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state" style={{ marginTop: 16 }}>
|
||||
<p>No records cached yet — sync a zone to populate statistics.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Secrets Column ── */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Secrets"
|
||||
hint="Expiry tracking for API tokens, certificates, and passwords."
|
||||
/>
|
||||
|
||||
<div className="stat-cards">
|
||||
<StatCard label="Monitored" value={totalSecrets} sub="total tracked" />
|
||||
<StatCard
|
||||
label="Expiring Soon"
|
||||
value={warningCount}
|
||||
sub="in warning window"
|
||||
accent={warningCount > 0 ? '#f59e0b' : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Expired"
|
||||
value={expiredCount}
|
||||
sub="need attention"
|
||||
accent={expiredCount > 0 ? '#ef4444' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{secretsByType.length > 0 ? (
|
||||
<div className="chart-section">
|
||||
<h3>Secrets by Type</h3>
|
||||
<div className="chart-wrapper">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={secretsByType}
|
||||
dataKey="count"
|
||||
nameKey="type"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={85}
|
||||
innerRadius={42}
|
||||
paddingAngle={2}
|
||||
label={({ type, percent }) =>
|
||||
percent > 0.05 ? `${(percent * 100).toFixed(0)}%` : ''
|
||||
}
|
||||
labelLine={false}
|
||||
>
|
||||
{secretsByType.map(entry => (
|
||||
<Cell key={entry.type} fill={secretTypeColor(entry.type)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<SecretTooltip />} />
|
||||
<Legend formatter={v => <span style={{ color: 'var(--text)', fontSize: 12 }}>{SECRET_TYPE_LABELS[v] ?? v}</span>} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state" style={{ marginTop: 16 }}>
|
||||
<p>No secrets yet — go to <strong>🔑 Secrets</strong> to add one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAllDomains } from '../api/dns';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return <span className="never-synced">Never synced</span>;
|
||||
const d = new Date(iso);
|
||||
return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
export default function DomainsPage({ onNavigate }) {
|
||||
const { colors } = useProviderColors();
|
||||
const [domains, setDomains] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showDupes, setShowDupes] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getAllDomains()
|
||||
.then(setDomains)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const duplicateCount = domains.filter(d => d.duplicate).length;
|
||||
|
||||
const filtered = domains.filter(d => {
|
||||
if (showDupes && !d.duplicate) return false;
|
||||
if (!filter) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return d.name.toLowerCase().includes(q) ||
|
||||
d.entries.some(e => e.provider.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function handleExport() {
|
||||
const rows = [];
|
||||
for (const d of filtered) {
|
||||
for (const e of d.entries) {
|
||||
rows.push({
|
||||
domain: d.name,
|
||||
provider: e.provider,
|
||||
record_count: e.record_count,
|
||||
synced_at: e.synced_at ?? '',
|
||||
duplicate: d.duplicate ? 'Yes' : 'No',
|
||||
});
|
||||
}
|
||||
}
|
||||
exportCsv(
|
||||
rows,
|
||||
['domain', 'provider', 'record_count', 'synced_at', 'duplicate'],
|
||||
{ domain: 'Domain', provider: 'Provider', record_count: 'Records', synced_at: 'Last Synced', duplicate: 'Duplicate' },
|
||||
'all-domains.csv'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<div>
|
||||
<h2>All Domains</h2>
|
||||
<p className="dashboard-hint">
|
||||
All zones across every provider — based on locally cached data. Sync a zone to update it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
|
||||
|
||||
<div className="records-actions" style={{ marginBottom: 16 }}>
|
||||
<input
|
||||
className="filter-input"
|
||||
placeholder="Filter by domain or provider…"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className={`btn-secondary ${showDupes ? 'active-filter' : ''}`}
|
||||
onClick={() => setShowDupes(v => !v)}
|
||||
title="Show only domains registered on multiple providers"
|
||||
>
|
||||
⚠ Duplicates {duplicateCount > 0 && <span className="dupe-badge">{duplicateCount}</span>}
|
||||
</button>
|
||||
<button className="btn-export" onClick={handleExport} title="Export to CSV">⬇ Export CSV</button>
|
||||
<span className="record-count">{filtered.length} domains</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="hint">Loading…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>{domains.length === 0 ? 'No domains cached yet — sync a zone first.' : 'No domains match your filter.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Provider</th>
|
||||
<th>Records</th>
|
||||
<th>Last Synced</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(d =>
|
||||
d.entries.map((e, i) => (
|
||||
<tr
|
||||
key={`${d.name}-${e.provider}`}
|
||||
className={d.duplicate ? 'dupe-row' : ''}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onNavigate && onNavigate(e.provider, e.zone_id, d.name)}
|
||||
title="Click to open this zone"
|
||||
>
|
||||
<td className="name-cell">
|
||||
{i === 0 && (
|
||||
<>
|
||||
{d.name}
|
||||
{d.duplicate && <span className="dupe-tag" title="Same domain on multiple providers">⚠ duplicate</span>}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td><span className="badge" style={{ ...providerBadgeStyle(colors, e.provider), textTransform: 'capitalize' }}>{e.provider}</span></td>
|
||||
<td>{e.record_count}</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatDate(e.synced_at)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getIpam, createIpEntry, updateIpEntry, deleteIpEntry } from '../api/dns';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
||||
|
||||
function isIPv6(address) {
|
||||
return address.includes(':');
|
||||
}
|
||||
|
||||
function IpForm({ initial, onSave, onCancel }) {
|
||||
const [form, setForm] = useState({
|
||||
address: initial?.address ?? '',
|
||||
label: initial?.label ?? '',
|
||||
vendor: initial?.vendor ?? '',
|
||||
location: initial?.location ?? '',
|
||||
notes: initial?.notes ?? '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function handleChange(e) {
|
||||
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
try { await onSave(form); }
|
||||
catch (err) { setError(err.message); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>{initial ? 'Edit IP Address' : 'Add IP Address'}</h3>
|
||||
<form onSubmit={handleSubmit} className="record-form">
|
||||
<div className="form-row">
|
||||
<label>IP Address
|
||||
<input name="address" value={form.address} onChange={handleChange} placeholder="1.2.3.4 or 2001:db8::1" required autoFocus disabled={!!initial} />
|
||||
</label>
|
||||
<label>Label
|
||||
<input name="label" value={form.label} onChange={handleChange} placeholder="e.g. Web Server 1" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>Vendor / Provider
|
||||
<input name="vendor" value={form.vendor} onChange={handleChange} placeholder="e.g. Hetzner, DigitalOcean" />
|
||||
</label>
|
||||
<label>Location / Region
|
||||
<input name="location" value={form.location} onChange={handleChange} placeholder="e.g. Frankfurt, US-East" />
|
||||
</label>
|
||||
</div>
|
||||
<label>Notes
|
||||
<input name="notes" value={form.notes} onChange={handleChange} placeholder="Optional notes" />
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : initial ? 'Save Changes' : 'Add IP'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsMatchBadges({ matches, colors }) {
|
||||
if (!matches || matches.length === 0) return <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>—</span>;
|
||||
return (
|
||||
<div className="ipam-dns-matches">
|
||||
{matches.map((m, i) => (
|
||||
<span key={i} className="ipam-dns-match" title={`${m.type} ${m.name}`}>
|
||||
<span className="badge" style={{ ...providerBadgeStyle(colors, m.provider), fontSize: 10, padding: '1px 5px' }}>{m.provider}</span>
|
||||
<span className="ipam-dns-name">{m.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IpamPage() {
|
||||
const { colors } = useProviderColors();
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
getIpam()
|
||||
.then(setEntries)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleSave(data) {
|
||||
if (editing) {
|
||||
const updated = await updateIpEntry(editing.id, data);
|
||||
setEntries(e => e.map(x => x.id === editing.id ? updated : x));
|
||||
} else {
|
||||
const created = await createIpEntry(data);
|
||||
setEntries(e => [...e, created]);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteIpEntry(confirmDel.id);
|
||||
setEntries(e => e.filter(x => x.id !== confirmDel.id));
|
||||
setConfirmDel(null);
|
||||
}
|
||||
|
||||
const filtered = entries.filter(e => {
|
||||
if (!filter) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return (
|
||||
e.address.includes(q) ||
|
||||
(e.label || '').toLowerCase().includes(q) ||
|
||||
(e.vendor || '').toLowerCase().includes(q) ||
|
||||
(e.location || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const v4count = entries.filter(e => !isIPv6(e.address)).length;
|
||||
const v6count = entries.filter(e => isIPv6(e.address)).length;
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2>IP Addresses</h2>
|
||||
<p className="dashboard-hint">
|
||||
{entries.length} address{entries.length !== 1 ? 'es' : ''} stored
|
||||
{entries.length > 0 && ` · ${v4count} IPv4 · ${v6count} IPv6`}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>+ Add IP</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
|
||||
|
||||
<div className="records-actions" style={{ marginBottom: 16 }}>
|
||||
<input className="filter-input" placeholder="Filter by IP, label, vendor…" value={filter} onChange={e => setFilter(e.target.value)} />
|
||||
<button className="btn-export" onClick={() =>
|
||||
exportCsv(
|
||||
filtered.map(e => ({
|
||||
address: e.address, label: e.label, vendor: e.vendor,
|
||||
location: e.location, notes: e.notes,
|
||||
dns_matches: (e.dnsMatches || []).map(m => `${m.name} (${m.provider})`).join('; '),
|
||||
})),
|
||||
['address','label','vendor','location','notes','dns_matches'],
|
||||
{ address:'IP Address', label:'Label', vendor:'Vendor', location:'Location', notes:'Notes', dns_matches:'DNS Records' },
|
||||
'ip-addresses.csv'
|
||||
)
|
||||
}>⬇ Export CSV</button>
|
||||
<span className="record-count">{filtered.length} entries</span>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="hint">Loading…</p> : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>{entries.length === 0 ? 'No IP addresses stored yet — press + Add IP to get started.' : 'No entries match your filter.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Label</th>
|
||||
<th>Vendor</th>
|
||||
<th>Location</th>
|
||||
<th>DNS Records</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(e => (
|
||||
<tr key={e.id}>
|
||||
<td>
|
||||
<span className={`ipam-address ${isIPv6(e.address) ? 'ipam-v6' : 'ipam-v4'}`}>{e.address}</span>
|
||||
</td>
|
||||
<td>{e.label || <span style={{ color: 'var(--text-muted)' }}>—</span>}</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{e.vendor || '—'}</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{e.location || '—'}</td>
|
||||
<td><DnsMatchBadges matches={e.dnsMatches} colors={colors} /></td>
|
||||
<td className="actions-cell">
|
||||
{e.notes && <span className="secret-notes" title={e.notes}>ℹ</span>}
|
||||
<button className="btn-edit" onClick={() => { setEditing(e); setShowForm(true); }} title="Edit">✎</button>
|
||||
<button className="btn-delete" onClick={() => setConfirmDel(e)} title="Delete">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<IpForm
|
||||
initial={editing}
|
||||
onSave={handleSave}
|
||||
onCancel={() => { setShowForm(false); setEditing(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDel && (
|
||||
<ConfirmDialog
|
||||
title="Delete IP Address"
|
||||
message={`Remove ${confirmDel.address}${confirmDel.label ? ` (${confirmDel.label})` : ''}?`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDel(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { login, setToken } from '../api/dns';
|
||||
|
||||
export default function LoginPage({ onLogin }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await login(username, password);
|
||||
setToken(data.token);
|
||||
onLogin(data.user);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrapper">
|
||||
<div className="login-box">
|
||||
<div className="login-logo">🦥</div>
|
||||
<h1 className="login-title">Sloth Manager</h1>
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" className="btn-primary login-btn" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="login-footer">By <a href="https://bobbantech.com" target="_blank" rel="noreferrer">bobbantech</a></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { changePassword } from '../api/dns';
|
||||
|
||||
export default function ProfilePage({ currentUser }) {
|
||||
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
setForm(f => ({ ...f, [name]: value }));
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (form.next !== form.confirm) { setError('New passwords do not match'); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await changePassword(form.current, form.next);
|
||||
setForm({ current: '', next: '', confirm: '' });
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 4000);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard" style={{ maxWidth: 480 }}>
|
||||
<div className="dashboard-header">
|
||||
<h2>My Profile</h2>
|
||||
</div>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>Account</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="profile-field">
|
||||
<span className="profile-label">Username</span>
|
||||
<span className="profile-value">{currentUser.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>Change Password</h3>
|
||||
<form onSubmit={handleSubmit} className="settings-form">
|
||||
<label>Current Password
|
||||
<input
|
||||
type="password"
|
||||
name="current"
|
||||
value={form.current}
|
||||
onChange={handleChange}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>New Password
|
||||
<input
|
||||
type="password"
|
||||
name="next"
|
||||
value={form.next}
|
||||
onChange={handleChange}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
<label>Confirm New Password
|
||||
<input
|
||||
type="password"
|
||||
name="confirm"
|
||||
value={form.confirm}
|
||||
onChange={handleChange}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{success && <p className="test-ok">✓ Password changed successfully.</p>}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getSyncStatus, syncRecords } from '../api/dns';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
||||
|
||||
const PROVIDER_LINKS = {
|
||||
cloudflare: { label: 'Open Cloudflare', url: 'https://dash.cloudflare.com/login' },
|
||||
loopia: { label: 'Open Loopia', url: 'https://customerzone.loopia.se' },
|
||||
pihole: { label: 'Open Pi-hole', url: null }, // dynamic — uses PIHOLE_URL
|
||||
azure: { label: 'Open Azure', url: 'https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.Network%2FdnsZones' },
|
||||
cpanel: { label: 'Open cPanel', url: null }, // dynamic — uses CPANEL_URL
|
||||
};
|
||||
|
||||
function formatSyncedAt(iso) {
|
||||
if (!iso) return <span className="never-synced">Never synced</span>;
|
||||
const d = new Date(iso);
|
||||
const date = d.toLocaleDateString('sv-SE');
|
||||
const time = d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
export default function ProviderOverview({ provider, providerMeta, zones, onSelectZone }) {
|
||||
const { colors } = useProviderColors();
|
||||
const link = PROVIDER_LINKS[provider];
|
||||
const externalUrl = (provider === 'pihole' || provider === 'cpanel') ? providerMeta?.url : link?.url;
|
||||
const [syncStatus, setSyncStatus] = useState({});
|
||||
const [syncingId, setSyncingId] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider) return;
|
||||
getSyncStatus(provider)
|
||||
.then(setSyncStatus)
|
||||
.catch(() => {});
|
||||
}, [provider]);
|
||||
|
||||
async function handleSync(e, zone) {
|
||||
e.stopPropagation(); // prevent row click opening the zone
|
||||
setSyncingId(zone.id);
|
||||
setError('');
|
||||
try {
|
||||
await syncRecords(provider, zone.id, zone.name);
|
||||
// Refresh sync status after sync completes
|
||||
const updated = await getSyncStatus(provider);
|
||||
setSyncStatus(updated);
|
||||
} catch (err) {
|
||||
setError(`Failed to sync ${zone.name}: ${err.message}`);
|
||||
} finally {
|
||||
setSyncingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="provider-overview">
|
||||
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ textTransform: 'capitalize' }}>{provider}</h2>
|
||||
<p className="dashboard-hint">{zones.length} zone{zones.length !== 1 ? 's' : ''} — click a row to manage its records, or sync directly from here.</p>
|
||||
</div>
|
||||
<button className="btn-export" onClick={() =>
|
||||
exportCsv(
|
||||
zones.map(z => ({ domain: z.name, synced_at: syncStatus[z.id]?.synced_at ?? '', records: syncStatus[z.id]?.record_count ?? '' })),
|
||||
['domain', 'records', 'synced_at'],
|
||||
{ domain: 'Domain', records: 'Records', synced_at: 'Last Synced' },
|
||||
`${provider}-domains.csv`
|
||||
)
|
||||
} title="Export to CSV">⬇ Export CSV</button>
|
||||
{externalUrl && (
|
||||
<a href={externalUrl} target="_blank" rel="noreferrer" className="btn-secondary" style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}>
|
||||
{link?.label ?? `Open ${provider}`} ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner" style={{ marginBottom: 16 }}>
|
||||
<strong>Error:</strong> {error}
|
||||
<button className="dismiss" onClick={() => setError('')}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone / Domain</th>
|
||||
<th>Records</th>
|
||||
<th>Last Synced</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zones.map(z => {
|
||||
const status = syncStatus[z.id];
|
||||
const isSyncing = syncingId === z.id;
|
||||
return (
|
||||
<tr key={z.id} className="zone-row" onClick={() => onSelectZone(z)}>
|
||||
<td className="name-cell">{z.name}</td>
|
||||
<td>{status?.record_count ?? '—'}</td>
|
||||
<td>{formatSyncedAt(status?.synced_at)}</td>
|
||||
<td className="actions-cell">
|
||||
<button
|
||||
className="btn-sync"
|
||||
onClick={(e) => handleSync(e, z)}
|
||||
disabled={isSyncing}
|
||||
title="Sync records from provider"
|
||||
>
|
||||
{isSyncing ? '…' : '⟳'}
|
||||
</button>
|
||||
<span className="zone-open" onClick={() => onSelectZone(z)}>Open →</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'content', label: 'Content' },
|
||||
{ key: 'ttl', label: 'TTL' },
|
||||
{ key: 'priority', label: 'Priority' },
|
||||
];
|
||||
|
||||
function compareValues(a, b) {
|
||||
// nulls always last
|
||||
if (a === null || a === undefined) return 1;
|
||||
if (b === null || b === undefined) return -1;
|
||||
// numeric comparison when both are numbers
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
// natural sort for strings (handles "sub10" > "sub9" correctly)
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export default function RecordsTable({ records, onDelete, onEdit, deleting, zoneName = 'records' }) {
|
||||
function handleExport() {
|
||||
exportCsv(
|
||||
records,
|
||||
['type', 'name', 'content', 'ttl', 'priority'],
|
||||
{ type: 'Type', name: 'Name', content: 'Content', ttl: 'TTL', priority: 'Priority' },
|
||||
`${zoneName}-dns-records.csv`
|
||||
);
|
||||
}
|
||||
const [sortKey, setSortKey] = useState('name');
|
||||
const [sortDir, setSortDir] = useState('asc');
|
||||
|
||||
function handleSort(key) {
|
||||
if (key === sortKey) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...records];
|
||||
copy.sort((a, b) => {
|
||||
const cmp = compareValues(a[sortKey], b[sortKey]);
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return copy;
|
||||
}, [records, sortKey, sortDir]);
|
||||
|
||||
function indicator(key) {
|
||||
if (key !== sortKey) return <span className="sort-icon inactive">↕</span>;
|
||||
return <span className="sort-icon active">{sortDir === 'asc' ? '↑' : '↓'}</span>;
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return <p className="empty">No DNS records found for this zone.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-actions">
|
||||
<button className="btn-export" onClick={handleExport} title="Export to CSV">⬇ Export CSV</button>
|
||||
</div>
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{COLUMNS.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="sortable"
|
||||
onClick={() => handleSort(col.key)}
|
||||
title={`Sort by ${col.label}`}
|
||||
>
|
||||
{col.label} {indicator(col.key)}
|
||||
</th>
|
||||
))}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td><span className={`type-badge type-${r.type?.toLowerCase() ?? 'unknown'}`}>{r.type ?? '?'}</span></td>
|
||||
<td className="name-cell">{r.name}</td>
|
||||
<td className="content-cell">{r.content}</td>
|
||||
<td>{r.ttl === 1 || r.ttl === null ? 'Auto' : r.ttl}</td>
|
||||
<td>{r.priority ?? '—'}</td>
|
||||
<td className="actions-cell">
|
||||
<button className="btn-edit" onClick={() => onEdit(r)} title="Edit record">✎</button>
|
||||
<button
|
||||
className="btn-delete"
|
||||
onClick={() => onDelete(r)}
|
||||
disabled={deleting === r.id}
|
||||
title="Delete record"
|
||||
>
|
||||
{deleting === r.id ? '…' : '✕'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSecrets, createSecret, updateSecret, deleteSecret } from '../api/dns';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
api_token: 'API Token',
|
||||
ssl_certificate: 'SSL Certificate',
|
||||
password: 'Password',
|
||||
generic: 'Generic',
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
expired: { label: 'Expired', cls: 'secret-expired' },
|
||||
warning: { label: 'Expiring', cls: 'secret-warning' },
|
||||
ok: { label: 'OK', cls: 'secret-ok' },
|
||||
};
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('sv-SE');
|
||||
}
|
||||
|
||||
function SecretForm({ initial, onSave, onCancel }) {
|
||||
const [form, setForm] = useState({
|
||||
name: initial?.name ?? '',
|
||||
type: initial?.type ?? 'api_token',
|
||||
description: initial?.description ?? '',
|
||||
expires_at: initial?.expires_at ? initial.expires_at.slice(0, 10) : '',
|
||||
warning_days: initial?.warning_days ?? 30,
|
||||
notes: initial?.notes ?? '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
setForm(f => ({ ...f, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await onSave({ ...form, warning_days: Number(form.warning_days) });
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>{initial ? 'Edit Secret' : 'Add Secret'}</h3>
|
||||
<form onSubmit={handleSubmit} className="record-form">
|
||||
<div className="form-row">
|
||||
<label>Name
|
||||
<input name="name" value={form.name} onChange={handleChange} required autoFocus />
|
||||
</label>
|
||||
<label>Type
|
||||
<select name="type" value={form.type} onChange={handleChange}>
|
||||
{Object.entries(TYPE_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>Description
|
||||
<input name="description" value={form.description} onChange={handleChange} placeholder="What is this secret used for?" />
|
||||
</label>
|
||||
<div className="form-row">
|
||||
<label>Expiry Date
|
||||
<input name="expires_at" type="date" value={form.expires_at} onChange={handleChange} required />
|
||||
</label>
|
||||
<label>Warn (days before)
|
||||
<input name="warning_days" type="number" min={1} value={form.warning_days} onChange={handleChange} required />
|
||||
</label>
|
||||
</div>
|
||||
<label>Notes
|
||||
<input name="notes" value={form.notes} onChange={handleChange} placeholder="Optional notes" />
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : initial ? 'Save Changes' : 'Add Secret'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SecretsPage() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
getSecrets()
|
||||
.then(setItems)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleSave(data) {
|
||||
if (editing) {
|
||||
const updated = await updateSecret(editing.id, data);
|
||||
setItems(i => i.map(x => x.id === editing.id ? { ...updated, daysLeft: x.daysLeft, status: x.status } : x));
|
||||
} else {
|
||||
await createSecret(data);
|
||||
load(); // reload to get fresh status
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteSecret(confirmDel.id);
|
||||
setItems(i => i.filter(x => x.id !== confirmDel.id));
|
||||
setConfirmDel(null);
|
||||
}
|
||||
|
||||
const filtered = items.filter(s => {
|
||||
if (filterStatus && s.status !== filterStatus) return false;
|
||||
if (!filter) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const expiredCount = items.filter(s => s.status === 'expired').length;
|
||||
const warningCount = items.filter(s => s.status === 'warning').length;
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2>Secrets</h2>
|
||||
<p className="dashboard-hint">Track expiry dates for API tokens, certificates, passwords, and other secrets.</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>+ Add Secret</button>
|
||||
</div>
|
||||
|
||||
{(expiredCount > 0 || warningCount > 0) && (
|
||||
<div className="secrets-alert-banner">
|
||||
{expiredCount > 0 && <span className="secret-expired-text">✕ {expiredCount} expired</span>}
|
||||
{warningCount > 0 && <span className="secret-warning-text">⚠ {warningCount} expiring soon</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
|
||||
|
||||
<div className="records-actions" style={{ marginBottom: 16 }}>
|
||||
<input className="filter-input" placeholder="Filter secrets…" value={filter} onChange={e => setFilter(e.target.value)} />
|
||||
<select className="filter-input" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="warning">Expiring soon</option>
|
||||
<option value="ok">OK</option>
|
||||
</select>
|
||||
<button className="btn-export" onClick={() =>
|
||||
exportCsv(
|
||||
filtered.map(s => ({ name: s.name, type: TYPE_LABELS[s.type] ?? s.type, description: s.description, expires_at: s.expires_at?.slice(0,10), warning_days: s.warning_days, status: s.status, days_left: s.daysLeft, notes: s.notes })),
|
||||
['name','type','description','expires_at','warning_days','status','days_left','notes'],
|
||||
{ name:'Name', type:'Type', description:'Description', expires_at:'Expires', warning_days:'Warn Days', status:'Status', days_left:'Days Left', notes:'Notes' },
|
||||
'secrets.csv'
|
||||
)
|
||||
}>⬇ Export CSV</button>
|
||||
<span className="record-count">{filtered.length} secrets</span>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="hint">Loading…</p> : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>{items.length === 0 ? 'No secrets tracked yet — press + Add Secret to get started.' : 'No secrets match your filter.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th>Expires</th>
|
||||
<th>Days Left</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(s => {
|
||||
const sc = STATUS_CONFIG[s.status] ?? STATUS_CONFIG.ok;
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td className="name-cell">
|
||||
{s.name}
|
||||
{s.notes && <span className="secret-notes" title={s.notes}> ℹ</span>}
|
||||
</td>
|
||||
<td><span className="type-badge">{TYPE_LABELS[s.type] ?? s.type}</span></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{s.description || '—'}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{formatDate(s.expires_at)}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||
{s.daysLeft < 0 ? `${Math.abs(s.daysLeft)}d ago` : `${s.daysLeft}d`}
|
||||
</td>
|
||||
<td><span className={`secret-status-badge ${sc.cls}`}>{sc.label}</span></td>
|
||||
<td className="actions-cell">
|
||||
<button className="btn-edit" onClick={() => { setEditing(s); setShowForm(true); }} title="Edit">✎</button>
|
||||
<button className="btn-delete" onClick={() => setConfirmDel(s)} title="Delete">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<SecretForm
|
||||
initial={editing}
|
||||
onSave={handleSave}
|
||||
onCancel={() => { setShowForm(false); setEditing(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDel && (
|
||||
<ConfirmDialog
|
||||
title="Delete Secret"
|
||||
message={`Delete "${confirmDel.name}"? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDel(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSettings, saveSettings, testNotification, clearCache, getUsers, createUser, deleteUser, getProviderHealth } from '../api/dns';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'notifications', label: 'Notifications' },
|
||||
{ id: 'providers', label: 'Providers' },
|
||||
{ id: 'status', label: 'Provider Status' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'cache', label: 'Cache' },
|
||||
];
|
||||
|
||||
// ─── Notifications tab ────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationsTab() {
|
||||
const [form, setForm] = useState({ enabled: false, url: '', token: '', priority: 5 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then(s => setForm({ ...s.gotify }))
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setForm(f => ({ ...f, [name]: type === 'checkbox' ? checked : value }));
|
||||
setSaved(false);
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
async function handleSave(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError(''); setSaved(false);
|
||||
try {
|
||||
await saveSettings({ gotify: { ...form, priority: Number(form.priority) } });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true); setTestResult(null); setError('');
|
||||
try {
|
||||
await testNotification({ url: form.url, token: form.token, priority: Number(form.priority) });
|
||||
setTestResult({ ok: true, message: 'Notification sent successfully.' });
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: err.message });
|
||||
} finally { setTesting(false); }
|
||||
}
|
||||
|
||||
if (loading) return <p className="hint">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section className="settings-section">
|
||||
<h3>Gotify Notifications</h3>
|
||||
<p className="settings-desc">
|
||||
Send a notification to a Gotify instance whenever a DNS record is added, updated, or deleted.
|
||||
</p>
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
<form onSubmit={handleSave} className="settings-form">
|
||||
<label className="toggle-row">
|
||||
<span>Enable notifications</span>
|
||||
<input type="checkbox" name="enabled" checked={form.enabled} onChange={handleChange} className="toggle" />
|
||||
</label>
|
||||
<label>Gotify URL
|
||||
<input name="url" type="url" value={form.url} onChange={handleChange} placeholder="http://gotify.example.com" disabled={!form.enabled} />
|
||||
</label>
|
||||
<label>App Token
|
||||
<input name="token" type="password" value={form.token} onChange={handleChange} placeholder="Your Gotify app token" disabled={!form.enabled} />
|
||||
</label>
|
||||
<label>Priority <span className="settings-hint">(1 = low, 10 = high)</span>
|
||||
<input name="priority" type="number" min={1} max={10} value={form.priority} onChange={handleChange} disabled={!form.enabled} />
|
||||
</label>
|
||||
{testResult && (
|
||||
<p className={testResult.ok ? 'test-ok' : 'test-fail'}>
|
||||
{testResult.ok ? '✓' : '✕'} {testResult.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={handleTest} disabled={testing || !form.url || !form.token}>
|
||||
{testing ? 'Sending…' : 'Send Test'}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : saved ? '✓ Saved' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>About Gotify</h3>
|
||||
<p className="settings-desc" style={{ lineHeight: 1.8 }}>
|
||||
<a href="https://gotify.net" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>Gotify</a> is a self-hosted push notification server.<br /><br />
|
||||
<strong>To get started:</strong><br />
|
||||
1. Log in to your Gotify instance<br />
|
||||
2. Go to <strong>Apps → Create application</strong><br />
|
||||
3. Copy the generated token and paste it into App Token<br /><br />
|
||||
Notifications fire on every record add, update, or delete — not on sync.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Users tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function UsersTab({ currentUser }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '' });
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [confirmUser, setConfirmUser] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => { getUsers().then(setUsers).catch(() => {}); }, []);
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault();
|
||||
setError(''); setSuccess('');
|
||||
setCreating(true);
|
||||
try {
|
||||
const user = await createUser(newUser.username, newUser.password);
|
||||
setUsers(u => [...u, user]);
|
||||
setNewUser({ username: '', password: '' });
|
||||
setShowForm(false);
|
||||
setSuccess(`User "${user.username}" created.`);
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setCreating(false); }
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setShowForm(false);
|
||||
setNewUser({ username: '', password: '' });
|
||||
setError('');
|
||||
}
|
||||
|
||||
async function confirmDeleteUser() {
|
||||
const user = confirmUser;
|
||||
setConfirmUser(null);
|
||||
try {
|
||||
await deleteUser(user.id);
|
||||
setUsers(u => u.filter(x => x.id !== user.id));
|
||||
} catch (err) { setError(err.message); }
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const date = d.toLocaleDateString('sv-SE');
|
||||
const time = d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmUser && (
|
||||
<ConfirmDialog
|
||||
title="Delete User"
|
||||
message={`Remove "${confirmUser.username}" from Sloth Manager? This cannot be undone.`}
|
||||
confirmLabel="Delete User"
|
||||
danger
|
||||
onConfirm={confirmDeleteUser}
|
||||
onCancel={() => setConfirmUser(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="records-header" style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600 }}>Users</h3>
|
||||
<p className="settings-desc" style={{ marginBottom: 0 }}>Manage who can access Sloth Manager.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn-export" onClick={() =>
|
||||
exportCsv(users, ['username', 'createdAt'], { username: 'Username', createdAt: 'Created' }, 'users.csv')
|
||||
} title="Export to CSV">⬇ Export CSV</button>
|
||||
<button className="btn-primary" onClick={() => setShowForm(true)} disabled={showForm}>+ Add User</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{success && <p className="test-ok" style={{ marginBottom: 12 }}>✓ {success}</p>}
|
||||
|
||||
<div className="table-wrapper" style={{ marginBottom: showForm ? 24 : 0 }}>
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<span className="user-name">{u.username}</span>
|
||||
{u.id === currentUser?.id && (
|
||||
<span className="badge" style={{ fontSize: 10, marginLeft: 8 }}>you</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{formatDate(u.createdAt)}</td>
|
||||
<td className="actions-cell">
|
||||
{u.id !== currentUser?.id && (
|
||||
<button className="btn-delete" onClick={() => setConfirmUser(u)} title="Delete user">✕</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<section className="settings-section" style={{ marginTop: 0 }}>
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 16 }}>New User</h4>
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
<form onSubmit={handleCreate} className="settings-form">
|
||||
<label>Username
|
||||
<input value={newUser.username} onChange={e => setNewUser(f => ({ ...f, username: e.target.value }))} required autoFocus />
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" value={newUser.password} onChange={e => setNewUser(f => ({ ...f, password: e.target.value }))} required minLength={6} />
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={handleCancel} disabled={creating}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={creating}>{creating ? 'Creating…' : 'Create User'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cache tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function CacheTab() {
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [cleared, setCleared] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function doClearCache() {
|
||||
setConfirmClear(false);
|
||||
setClearing(true); setError('');
|
||||
try {
|
||||
await clearCache();
|
||||
setCleared(true);
|
||||
setTimeout(() => setCleared(false), 3000);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setClearing(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmClear && (
|
||||
<ConfirmDialog
|
||||
title="Clear Record Cache"
|
||||
message="This will delete all locally cached DNS records. All zones will need to be re-synced afterwards."
|
||||
confirmLabel="Clear Cache"
|
||||
danger
|
||||
onConfirm={doClearCache}
|
||||
onCancel={() => setConfirmClear(false)}
|
||||
/>
|
||||
)}
|
||||
<section className="settings-section">
|
||||
<h3>Record Cache</h3>
|
||||
<p className="settings-desc">
|
||||
The record cache stores a local copy of all synced DNS records in <code>dns-cache.json</code>.
|
||||
Clear it if you experience stale or corrupt data — all zones will need to be re-synced afterwards.
|
||||
</p>
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
|
||||
<button type="button" className="btn-danger" onClick={() => setConfirmClear(true)} disabled={clearing}>
|
||||
{clearing ? 'Clearing…' : cleared ? '✓ Cache cleared' : 'Clear Cache'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Providers tab ───────────────────────────────────────────────────────────
|
||||
|
||||
const PROVIDER_NAMES = {
|
||||
cloudflare: 'Cloudflare',
|
||||
loopia: 'Loopia',
|
||||
pihole: 'Pi-hole',
|
||||
azure: 'Azure DNS',
|
||||
cpanel: 'cPanel',
|
||||
};
|
||||
|
||||
function ProvidersTab() {
|
||||
const { colors, setColors } = useProviderColors();
|
||||
const [local, setLocal] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => { setLocal({ ...colors }); }, [colors]);
|
||||
|
||||
function handleChange(provider, value) {
|
||||
setLocal(c => ({ ...c, [provider]: value }));
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true); setError(''); setSaved(false);
|
||||
try {
|
||||
await saveSettings({ providerColors: local });
|
||||
setColors(local);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<h3>Provider Tag Colors</h3>
|
||||
<p className="settings-desc">Customize the color of each provider's badge throughout the app.</p>
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
<div className="provider-colors-list">
|
||||
{Object.entries(PROVIDER_NAMES).map(([id, name]) => (
|
||||
<div key={id} className="provider-color-row">
|
||||
<span className="badge" style={providerBadgeStyle(local, id)}>{name}</span>
|
||||
<label className="color-label">
|
||||
<input
|
||||
type="color"
|
||||
value={local[id] ?? '#6366f1'}
|
||||
onChange={e => handleChange(id, e.target.value)}
|
||||
className="color-input"
|
||||
/>
|
||||
<span className="color-hex">{local[id] ?? '#6366f1'}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-actions" style={{ marginTop: 20 }}>
|
||||
<button className="btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : saved ? '✓ Saved' : 'Save Colors'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Status tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusTab() {
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function runCheck() {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await getProviderHealth();
|
||||
setResults(data);
|
||||
setLastChecked(new Date());
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
ok: { icon: '✓', label: 'Connected', cls: 'health-ok' },
|
||||
error: { icon: '✕', label: 'Error', cls: 'health-error' },
|
||||
unconfigured: { icon: '—', label: 'Not configured', cls: 'health-unconfigured' },
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div>
|
||||
<h3>Provider Status</h3>
|
||||
<p className="settings-desc" style={{ marginBottom: 0 }}>
|
||||
Test connectivity to each configured provider.
|
||||
{lastChecked && <span> Last checked: {lastChecked.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={runCheck} disabled={loading}>
|
||||
{loading ? 'Checking…' : '⟳ Run Check'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
|
||||
{results.length === 0 && !loading && (
|
||||
<p className="hint" style={{ marginTop: 16 }}>Press <strong>Run Check</strong> to test all provider connections.</p>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="table-wrapper" style={{ marginTop: 16 }}>
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Provider</th>
|
||||
<th>Status</th>
|
||||
<th>Latency</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map(r => {
|
||||
const cfg = STATUS_CONFIG[r.status] ?? STATUS_CONFIG.unconfigured;
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td style={{ fontWeight: 500 }}>{r.name}</td>
|
||||
<td>
|
||||
<span className={`health-badge ${cfg.cls}`}>
|
||||
{cfg.icon} {cfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{r.latency !== null ? `${r.latency} ms` : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: 13, color: r.error ? '#fca5a5' : 'var(--text-muted)' }}>
|
||||
{r.error ?? (r.status === 'unconfigured' ? 'No credentials in .env' : 'OK')}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Settings page ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage({ currentUser }) {
|
||||
const [activeTab, setActiveTab] = useState('notifications');
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="dashboard-header">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
|
||||
<div className="settings-tabs">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`settings-tab ${activeTab === t.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="settings-tab-content">
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'providers' && <ProvidersTab />}
|
||||
{activeTab === 'status' && <StatusTab />}
|
||||
{activeTab === 'users' && <UsersTab currentUser={currentUser} />}
|
||||
{activeTab === 'cache' && <CacheTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { getSettings } from '../api/dns';
|
||||
|
||||
const DEFAULTS = {
|
||||
cloudflare: '#f6821f',
|
||||
loopia: '#2ecc71',
|
||||
pihole: '#96060c',
|
||||
azure: '#0078d4',
|
||||
cpanel: '#ff6c2c',
|
||||
};
|
||||
|
||||
const ProviderColorsContext = createContext(DEFAULTS);
|
||||
|
||||
export function ProviderColorsProvider({ children }) {
|
||||
const [colors, setColors] = useState(DEFAULTS);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then(s => setColors({ ...DEFAULTS, ...(s.providerColors ?? {}) }))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProviderColorsContext.Provider value={{ colors, setColors }}>
|
||||
{children}
|
||||
</ProviderColorsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProviderColors() {
|
||||
return useContext(ProviderColorsContext);
|
||||
}
|
||||
|
||||
export function providerBadgeStyle(colors, provider) {
|
||||
const color = colors[provider?.toLowerCase()] ?? '#6366f1';
|
||||
return {
|
||||
background: `${color}22`,
|
||||
color,
|
||||
border: `1px solid ${color}55`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const ThemeContext = createContext({ theme: 'dark', toggleTheme: () => {} });
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('dns_theme') || 'dark');
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('dns_theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme(t => t === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { ProviderColorsProvider } from './context/ProviderColors';
|
||||
import { ThemeProvider } from './context/Theme';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<ProviderColorsProvider>
|
||||
<App />
|
||||
</ProviderColorsProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Download an array of objects as a CSV file.
|
||||
* @param {object[]} rows - data rows
|
||||
* @param {string[]} columns - keys to include (in order)
|
||||
* @param {object} headers - optional { key: 'Label' } overrides
|
||||
* @param {string} filename
|
||||
*/
|
||||
export function exportCsv(rows, columns, headers = {}, filename = 'export.csv') {
|
||||
const header = columns.map(c => headers[c] ?? c).join(',');
|
||||
|
||||
const body = rows.map(row =>
|
||||
columns.map(col => {
|
||||
const val = row[col] ?? '';
|
||||
const str = String(val);
|
||||
// Wrap in quotes if the value contains a comma, quote, or newline
|
||||
return str.includes(',') || str.includes('"') || str.includes('\n')
|
||||
? `"${str.replace(/"/g, '""')}"`
|
||||
: str;
|
||||
}).join(',')
|
||||
);
|
||||
|
||||
const csv = [header, ...body].join('\r\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user