Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,42 @@ This project follows a monorepo-style structure:

See the [Development Guide](apps/docs/DEVELOPMENT.md) for more details on project structure and testing.

## Prerequisites

- **Docker** and **Docker Compose**
- **curl** (used for health checks in the container)

Install curl if needed:

| Platform | Command |
|----------|---------|
| Ubuntu/Debian | `sudo apt-get install -y curl` |
| Fedora/RHEL | `sudo dnf install -y curl` |
| macOS | `brew install curl` |
| Alpine | `apk add curl` |

## Quick Start

**One-liner (curl):** When piped, the script runs non-interactively and uses defaults.
```bash
curl -fsSL https://raw.githubusercontent.com/n3-rd/multi-pb/main/install.sh | bash
```
With options (port, data dir, domain):
```bash
curl -fsSL https://raw.githubusercontent.com/n3-rd/multi-pb/main/install.sh | bash -s -- --non-interactive --port 25983 --data-dir ./multipb-data
```

Or interactive (will prompt for config):
```bash
curl -fsSL https://raw.githubusercontent.com/n3-rd/multi-pb/main/install.sh | bash -s --
```

**From clone:**
```bash
git clone https://github.com/n3-rd/multi-pb.git
cd multi-pb
# Recommended: Use Bun for speed
bun install
bun install
./install.sh
```

Expand Down
21 changes: 19 additions & 2 deletions apps/dashboard/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@
</div>

<nav class="flex-1 space-y-1">
<button
<a
href="."
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg bg-emerald-500/10 text-emerald-400 font-medium text-sm transition-all border border-emerald-500/20"
>
<svg
Expand All @@ -270,7 +271,23 @@
/></svg
>
Dashboard
</button>
</a>
<a
href="settings"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-500 hover:bg-gray-800/50 hover:text-gray-300 font-medium text-sm transition-all"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><circle cx="12" cy="12" r="3" /><path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"
/></svg
>
Settings
</a>
<a
href="https://pocketbase.io/docs"
target="_blank"
Expand Down
165 changes: 165 additions & 0 deletions apps/dashboard/src/routes/settings/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiFetch } from '$lib/api';

let dnsDomain = '';
let saving = false;
let error: string | null = null;
let success = false;

async function loadDns() {
try {
const res = await apiFetch('/dns/config');
if (res.ok) {
const data = await res.json();
dnsDomain = data.domain || '';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load DNS settings';
}
}

let warning: string | null = null;

async function saveDns() {
saving = true;
error = null;
success = false;
warning = null;
try {
const res = await apiFetch('/dns/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: dnsDomain })
});
const data = await res.json();
if (res.status === 400) throw new Error(data.error || 'Invalid domain');
if (res.status === 207) {
dnsDomain = data.domain || '';
warning = data.warning + (data.reloadError ? `: ${data.reloadError}` : '');
return;
}
if (!res.ok) throw new Error(data.error || 'Failed to save');
dnsDomain = data.domain || '';
success = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save DNS settings';
} finally {
saving = false;
}
}

onMount(() => loadDns());
</script>

<div class="flex min-h-screen bg-[#0a0a0a] text-gray-100 font-sans">
<!-- Sidebar (match main dashboard) -->
<aside class="w-64 bg-[#111] border-r border-gray-800/50 flex flex-col p-5">
<div class="flex items-center gap-3 mb-10 px-2">
<div class="w-9 h-9 bg-emerald-500 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-black" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<span class="text-lg font-bold tracking-tight">Multi-PB</span>
</div>

<nav class="flex-1 space-y-1">
<a
href=".."
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-500 hover:bg-gray-800/50 hover:text-gray-300 font-medium text-sm transition-all"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
</svg>
Dashboard
</a>
<div
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg bg-emerald-500/10 text-emerald-400 font-medium text-sm border border-emerald-500/20"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
Settings
</div>
<a
href="https://pocketbase.io/docs"
target="_blank"
rel="noopener"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-500 hover:bg-gray-800/50 hover:text-gray-300 font-medium text-sm transition-all"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
</svg>
PocketBase Docs
</a>
</nav>

<div class="mt-auto pt-4 border-t border-gray-800/50 space-y-1">
<div class="flex items-center gap-3 px-2">
<div class="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold text-emerald-400">A</div>
<div>
<p class="text-sm font-medium text-gray-300">Admin</p>
<p class="text-xs text-gray-600">Super User</p>
</div>
</div>
</div>
</aside>

<!-- Main Content -->
<main class="flex-1 p-6 overflow-y-auto">
<header class="mb-8">
<h1 class="text-2xl font-bold text-white mb-1">Settings</h1>
<p class="text-gray-500 text-sm">DNS and system configuration</p>
</header>

{#if error}
<div class="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg mb-6 text-sm">
{error}
<button on:click={() => (error = null)} class="float-right text-red-400 hover:text-red-300">&times;</button>
</div>
{/if}
{#if warning}
<div class="bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 px-4 py-3 rounded-lg mb-6 text-sm">
{warning}
<button on:click={() => (warning = null)} class="float-right text-yellow-400 hover:text-yellow-300">&times;</button>
</div>
{/if}
{#if success}
<div class="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 px-4 py-3 rounded-lg mb-6 text-sm">
DNS settings saved. Caddy has been reloaded.
</div>
{/if}

<!-- DNS Settings -->
<div class="bg-[#111] rounded-xl border border-gray-800/50 p-6 max-w-xl">
<h2 class="text-lg font-semibold text-white mb-1">DNS / Domain</h2>
<p class="text-gray-500 text-sm mb-4">
Set a domain to serve over HTTPS. Caddy will obtain and renew TLS certificates. Leave empty to use HTTP on the configured port.
</p>
<div class="flex gap-3 items-end">
<div class="flex-1">
<label for="dns-domain" class="block text-xs font-medium text-gray-500 uppercase mb-2">Domain</label>
<input
id="dns-domain"
type="text"
bind:value={dnsDomain}
placeholder="pb.example.com"
class="w-full bg-[#0a0a0a] border border-gray-800 rounded-lg px-4 py-2.5 focus:outline-none focus:border-emerald-500/50 transition-all text-white placeholder-gray-600 font-mono text-sm"
/>
</div>
<button
on:click={saveDns}
disabled={saving}
class="px-5 py-2.5 bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-black rounded-lg font-semibold text-sm transition-all"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
<p class="text-xs text-gray-600 mt-2">
Ensure DNS for this domain points to this server. Ports 80 and 443 must be exposed (e.g. in docker-compose).
</p>
</div>
</main>
</div>
58 changes: 58 additions & 0 deletions core/api/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ async function loadConfig() {
config = {
notifications: { webhookUrl: "" },
monitoring: { intervalSeconds: 60, historyRetentionCount: 100 },
dns: { domain: "" },
};
}
if (!config.dns) config.dns = { domain: "" };

// Load admin token from config (priority) or environment
// Empty strings are falsy and will result in null (authorization disabled)
Expand Down Expand Up @@ -596,6 +598,62 @@ const server = http.createServer(async (req, res) => {
});
}

// GET /api/dns/config
if (pathname === "/api/dns/config" && req.method === "GET") {
// Config wins over env (so dashboard edits are always reflected)
const domain =
config.dns?.domain ||
process.env.MULTIPB_DOMAIN ||
"";
return sendJson(200, { domain });
}

// PATCH /api/dns/config
if (pathname === "/api/dns/config" && req.method === "PATCH") {
const body = await parseBody(req);
const domain =
typeof body.domain === "string" ? body.domain.trim() : "";

// Validate domain if non-empty
if (domain) {
if (domain.includes("://")) {
return sendJson(400, { error: "Enter a domain name, not a URL (no http:// or https://)" });
}
if (/\s/.test(domain)) {
return sendJson(400, { error: "Domain must not contain spaces" });
}
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test(domain)) {
return sendJson(400, { error: "Invalid domain format" });
}
}

config.dns = config.dns || {};
config.dns.domain = domain;
await fs.writeFile(
CONFIG_FILE,
JSON.stringify(config, null, 2),
"utf8",
);

// Reload proxy — surface errors to the caller
let reloadError = null;
try {
await execAsync("/usr/local/bin/reload-proxy.sh");
} catch (e) {
console.error("reload-proxy after DNS update:", e.message);
reloadError = e.stderr || e.message;
}

if (reloadError) {
return sendJson(207, {
domain,
warning: "Domain saved but Caddy reload failed",
reloadError,
});
}
return sendJson(200, { domain });
}

// GET /api/instances
if (pathname === "/api/instances" && req.method === "GET") {
const manifest = await readManifest();
Expand Down
46 changes: 45 additions & 1 deletion core/cli/reload-proxy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,23 @@ MANIFEST_FILE="/var/multipb/data/instances.json"
CADDYFILE="/etc/caddy/Caddyfile"
MULTIPB_PORT="${MULTIPB_PORT:-25983}"

# Domain: config.json takes priority, then env (so dashboard edits always win)
if [ -f "/var/multipb/data/config.json" ] && command -v jq >/dev/null 2>&1; then
CONFIG_DOMAIN=$(jq -r '.dns.domain // empty' /var/multipb/data/config.json 2>/dev/null)
fi
if [ -n "$CONFIG_DOMAIN" ]; then
MULTIPB_DOMAIN="$CONFIG_DOMAIN"
else
MULTIPB_DOMAIN="${MULTIPB_DOMAIN:-}"
fi

echo "Regenerating Caddy configuration..."

# Start building Caddyfile
echo "Building Caddyfile with DOMAIN=${MULTIPB_DOMAIN} PORT=${MULTIPB_PORT}"

if [ -n "$MULTIPB_DOMAIN" ]; then
# HTTPS Mode (Domain provided)
# HTTPS + port fallback: domain block for public traffic, port block for local/dashboard access
cat > "$CADDYFILE" << EOF
{
admin localhost:2019
Expand Down Expand Up @@ -110,6 +120,40 @@ cat >> "$CADDYFILE" << 'EOF'
}
EOF

# When using a domain, add a port-based fallback so the dashboard/API stays reachable
# even if 80/443 aren't exposed or DNS isn't pointing here yet.
if [ -n "$MULTIPB_DOMAIN" ]; then
cat >> "$CADDYFILE" << EOF

# Fallback: always keep port ${MULTIPB_PORT} listening for local/direct access
http://:${MULTIPB_PORT} {
handle /_health {
respond "OK" 200
}
handle /api/* {
reverse_proxy 127.0.0.1:3001
}
EOF
if [ -d "/var/www/dashboard" ] && [ -f "/var/www/dashboard/index.html" ]; then
cat >> "$CADDYFILE" << 'EOF'
handle /dashboard* {
root * /var/www
try_files {path} {path}/ /dashboard/index.html
file_server
}
handle / {
redir /dashboard/ 301
}
EOF
fi
cat >> "$CADDYFILE" << 'EOF'
handle {
respond "Multi-PB - Use the configured domain for full access" 200
}
}
EOF
fi

# Replace variables
sed -i "s|\${INSTANCE_LIST}|$INSTANCE_LIST|g" "$CADDYFILE"

Expand Down
Loading