reorg
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
my-storage
|
||||||
|
certs
|
||||||
|
.env
|
||||||
|
cache/*
|
||||||
|
package-lock.json
|
||||||
|
backups
|
||||||
|
p2ns.json
|
||||||
|
plugin-sites/**/app.log
|
||||||
|
plugin-sites/**/db
|
||||||
|
plugin-sites/**/spec
|
||||||
|
plugin-sites/**/drives
|
||||||
|
plugin-sites/replication.example
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# P2NS: Peer-to-Peer Decentralized DNS System
|
||||||
|
|
||||||
|
P2NS (Peer-to-Peer Name System) is a firewall-resistant P2P DNS resolution system independent of centralized DNS infrastructure. Using UDP hole-punching via Holesail, it enables connectivity across NAT, CGNAT, and restricted networks (4G/5G, Starlink).
|
||||||
|
|
||||||
|
Built with Node.js, integrating Corestore, Hyperswarm, Autopass, and Holesail for decentralized storage, peer discovery, secure invitations, and dynamic tunneling.
|
||||||
|
|
||||||
|
[](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/domains-tab.png)
|
||||||
|
|
||||||
|
Example Peer-to-Peer domain: https://cert.decode (globally avalible to all peers)
|
||||||
|
|
||||||
|
[](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/p2p-domain-cert-dot-decode.png)
|
||||||
|
|
||||||
|
Local Example Plugin site with Peer-to-Peer access via the P2NS SDK.
|
||||||
|
|
||||||
|
[](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/internal-domain-example-dot-plugin.png)
|
||||||
|
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Features](#features)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Architecture Overview](#architecture-overview)
|
||||||
|
- [Adding Domains](#adding-domains)
|
||||||
|
- [Admin Interface](#admin-interface)
|
||||||
|
- [DNS Resolution](#dns-resolution)
|
||||||
|
- [Proxying and Tunneling](#proxying-and-tunneling)
|
||||||
|
- [Certificate Authority](#certificate-authority)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
- [Security Considerations](#security-considerations)
|
||||||
|
- [Additional Documentation](#additional-documentation)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Decentralized DNS** - Resolves domains via P2P, independent of ICANN
|
||||||
|
- **NAT Traversal** - UDP hole-punching via Holesail
|
||||||
|
- **Hybrid DNS** - Falls back to public DNS for non-P2P domains
|
||||||
|
- **HTTP/HTTPS Proxy** - With WebSocket support and auto HTTP→HTTPS redirect
|
||||||
|
- **TLS Certificates** - Auto-generated root CA and per-domain certs
|
||||||
|
- **Consensus Voting** - Resolves domain claim conflicts across peers
|
||||||
|
- **Plugin System** - Extensible with custom plugins
|
||||||
|
- **Web Admin** - Real-time management at `https://p2ns.admin`
|
||||||
|
- **Peer Directory** - Browse domains at `https://peer.directory`
|
||||||
|
- **Service Subscriptions** - Auto-subscribe to services from other domains
|
||||||
|
- **Backup/Recovery** - Automatic backups with rotation and restore
|
||||||
|
- **Health Monitoring** - Liveness/readiness probes, diagnostics, metrics
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- macOS or Linux (Windows planned)
|
||||||
|
- `npm install -g holesail` for hash generation
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.ssh.surf/snxraven/p2ns.git
|
||||||
|
cd p2ns
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Master node (initializes network)
|
||||||
|
sudo node p2ns.js --master
|
||||||
|
|
||||||
|
# Joiner node (connects via invites)
|
||||||
|
sudo node p2ns.js
|
||||||
|
|
||||||
|
# Fresh start (clears storage)
|
||||||
|
sudo node p2ns.js --clean [--master]
|
||||||
|
```
|
||||||
|
|
||||||
|
The system binds to UDP 53 (DNS), TCP 443 (HTTPS), and TCP 80 (HTTP redirect).
|
||||||
|
|
||||||
|
Access the admin interface at `https://p2ns.admin` (trust the root CA in your browser).
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ P2NS Core │
|
||||||
|
├─────────────┬─────────────┬─────────────┬──────────────────┤
|
||||||
|
│ Corestore │ Hyperswarm │ Autopass │ Holesail │
|
||||||
|
│ (Storage) │ (Peers) │ (Invites) │ (Tunneling) │
|
||||||
|
├─────────────┴─────────────┴─────────────┴──────────────────┤
|
||||||
|
│ DNS Server (UDP 53) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ HTTPS Proxy (443) / HTTP Redirect (80) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Virtual Interfaces │
|
||||||
|
│ (lo0/lo IP aliases) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Plugin System │
|
||||||
|
│ (peer.directory, p2ns.admin, custom) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Key components:
|
||||||
|
- **DNS** (`dns.js`) - P2P/public/local DNS resolution on port 53
|
||||||
|
- **Proxy** (`*_proxy.js`) - HTTPS routing with SNI, WebSocket support
|
||||||
|
- **Holesail** (`holesail.js`) - Persistent tunnels for P2P domains
|
||||||
|
- **Virtual Interfaces** (`virtual_interfaces.js`) - IP aliases for domains
|
||||||
|
- **Certificates** (`certificate_authority.js`) - Root CA and domain certs
|
||||||
|
- **Plugins** (`plugin-handler.js`) - Extensible internal domains
|
||||||
|
|
||||||
|
## Adding Domains
|
||||||
|
|
||||||
|
1. **Generate a Holesail hash:**
|
||||||
|
```bash
|
||||||
|
holesail --live 80 --public
|
||||||
|
# Output: Connection hash: hs://<hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add via Admin Interface** (recommended):
|
||||||
|
Use the "Domains" tab at `https://p2ns.admin`
|
||||||
|
|
||||||
|
3. **Add via JSON:**
|
||||||
|
Edit `cache/domains.json`:
|
||||||
|
```json
|
||||||
|
[{"domain": "example.tld", "hash": "hs://<hash>", "ssl": false}]
|
||||||
|
```
|
||||||
|
|
||||||
|
Domains sync across peers via Autopass with consensus-based conflict resolution. See [docs/CONSENSUS.md](docs/CONSENSUS.md) for details.
|
||||||
|
|
||||||
|
## Admin Interface
|
||||||
|
|
||||||
|
Access `https://p2ns.admin` for real-time management:
|
||||||
|
|
||||||
|
| Tab | Features |
|
||||||
|
|-----|----------|
|
||||||
|
| **Domains** | Add/remove domains, view consensus status |
|
||||||
|
| **Host** | Holesail servers/clients, service subscriptions |
|
||||||
|
| **Local DNS** | Custom DNS records, conflict selector |
|
||||||
|
| **Entries** | Autopass ledger (claims/votes) |
|
||||||
|
| **Peers** | Connected peers, blocking, metrics |
|
||||||
|
| **Certificates** | Generate/regenerate domain certs, manage CA |
|
||||||
|
| **Interfaces** | Domain-to-IP mappings |
|
||||||
|
| **Backups** | Create/restore backups |
|
||||||
|
| **Diagnostics** | DNS lookup, ping, traceroute, connection tests |
|
||||||
|
| **Stats** | Real-time metrics, health status |
|
||||||
|
| **Logs** | Live system logs |
|
||||||
|
| **Settings** | Environment config, subnet management |
|
||||||
|
| **Plugins** | Start/stop plugins, view logs, configure settings |
|
||||||
|
|
||||||
|
## DNS Resolution
|
||||||
|
|
||||||
|
P2NS provides hybrid DNS resolution:
|
||||||
|
|
||||||
|
| Type | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| **P2P Domains** | Resolve to local IPs, start Holesail tunnels |
|
||||||
|
| **Public Domains** | Forward to public DNS (default: 1.1.1.1) |
|
||||||
|
| **Local DNS** | Serve from `cache/local_dns.json` |
|
||||||
|
| **Internal Domains** | Map to 127.0.0.1 (plugins) |
|
||||||
|
| **Conflicting** | Managed via DNS Conflict Selector in admin |
|
||||||
|
|
||||||
|
Test with: `dig @127.0.0.1 example.tld`
|
||||||
|
|
||||||
|
### DNS Conflict Selector
|
||||||
|
|
||||||
|
For domains with both P2P and public records, use the "Local DNS" tab to toggle between P2P and Public resolution. Preferences are persisted in `cache/selector_cache.json`.
|
||||||
|
|
||||||
|
## Proxying and Tunneling
|
||||||
|
|
||||||
|
- **HTTPS Proxy** (443) - Routes to Holesail tunnels or public IPs
|
||||||
|
- **HTTP Redirect** (80) - Redirects to HTTPS
|
||||||
|
- **TLS/SNI** - Per-domain certificate selection
|
||||||
|
- **WebSocket** - Full upgrade support
|
||||||
|
- **Holesail Clients** - Lazy start, timeout after 5 min (configurable)
|
||||||
|
- **Holesail Servers** - Persistent, managed via admin
|
||||||
|
|
||||||
|
### Service Subscriptions
|
||||||
|
|
||||||
|
Subscribe to services published by other domains. See [docs/SUBSCRIPTIONS.md](docs/SUBSCRIPTIONS.md) for details.
|
||||||
|
|
||||||
|
## Certificate Authority
|
||||||
|
|
||||||
|
P2NS generates a root CA (`certs/ca.cert.pem`) and per-domain certificates with SANs.
|
||||||
|
|
||||||
|
### Auto-Installation
|
||||||
|
|
||||||
|
The CA is automatically installed on macOS (Keychain) and Linux (`/usr/local/share/ca-certificates`).
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
|
||||||
|
If auto-install fails, see [docs/CERTIFICATES.md](docs/CERTIFICATES.md) for manual installation instructions for:
|
||||||
|
- macOS (Keychain Access or command line)
|
||||||
|
- Linux (system certificates)
|
||||||
|
- Windows (certutil)
|
||||||
|
- Browser-specific (Firefox, Chrome)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configure via `.env` (copy from `default.env`):
|
||||||
|
|
||||||
|
### Core Settings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `STORAGE_DIR` | `./my-storage` | Corestore data directory |
|
||||||
|
| `CERTS_DIR` | `./certs` | Certificate storage |
|
||||||
|
| `LOG_LEVEL` | `0` | 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR |
|
||||||
|
| `TOPIC_SEED` | `p2ns-dns` | Hyperswarm topic seed |
|
||||||
|
|
||||||
|
### File Paths
|
||||||
|
|
||||||
|
| Variable | Default |
|
||||||
|
|----------|---------|
|
||||||
|
| `DOMAINS_FILE` | `./cache/domains.json` |
|
||||||
|
| `LOCAL_DNS_FILE` | `./cache/local_dns.json` |
|
||||||
|
| `HOLESAIL_SERVERS_FILE` | `./cache/holesail_servers.json` |
|
||||||
|
| `HOLESAIL_CLIENTS_FILE` | `./cache/holesail_clients.json` |
|
||||||
|
| `SELECTOR_CACHE_FILE` | `./cache/selector_cache.json` |
|
||||||
|
| `SUBSCRIPTIONS_FILE` | `./cache/subscriptions.json` |
|
||||||
|
| `PEER_HISTORY_FILE` | `./cache/peer_history.json` |
|
||||||
|
| `PEER_METRICS_FILE` | `./cache/peer_metrics.json` |
|
||||||
|
|
||||||
|
### Network Settings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DNS_PORT` | `53` | DNS server port |
|
||||||
|
| `HTTPS_PORT` | `443` | HTTPS proxy port |
|
||||||
|
| `HTTP_PORT` | `80` | HTTP redirect port |
|
||||||
|
| `INTERNAL_PORT` | `8080` | Holesail client port |
|
||||||
|
| `PUBLIC_DNS_SERVER` | `1.1.1.1` | Fallback DNS (comma-separated for failover) |
|
||||||
|
|
||||||
|
### Feature Toggles
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DISABLE_DNS_SERVER` | `false` | Disable DNS server |
|
||||||
|
| `DISABLE_PROXY_SERVER` | `false` | Disable proxy servers |
|
||||||
|
| `DISABLE_VIRTUAL_INTERFACES` | `false` | Disable virtual interfaces |
|
||||||
|
| `ALLOW_ANY_WRITER_INVITES` | `false` | Allow joiners to issue invites |
|
||||||
|
| `FULL_PERSISTENCE` | `false` | Keep Holesail connections indefinitely |
|
||||||
|
|
||||||
|
### Consensus Settings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CONSENSUS_QUORUM_THRESHOLD` | `0.5` | Percentage of peers required (0.0-1.0) |
|
||||||
|
| `CONSENSUS_MIN_VOTES` | `2` | Minimum votes required |
|
||||||
|
| `CONSENSUS_TIE_BREAKER` | `timestamp` | `timestamp`, `claimant_age`, or `lexicographic` |
|
||||||
|
|
||||||
|
### Subnet Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SUBNETS` | Single 192.168.3.x | JSON array of subnet configs |
|
||||||
|
| `SUBNET_NAME` | `lo0`/`lo` | Network interface for virtual IPs |
|
||||||
|
|
||||||
|
Example multi-subnet config:
|
||||||
|
```json
|
||||||
|
[{"base":"192.168.3.0","cidr":24,"startIndex":2,"name":"Primary"}]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup & Metrics
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `BACKUP_DIR` | `./backups` | Backup storage |
|
||||||
|
| `BACKUP_RETENTION` | `25` | Backups to keep |
|
||||||
|
| `BACKUP_INTERVAL` | `720` | Auto-backup interval (minutes) |
|
||||||
|
| `METRICS_RETENTION_MS` | `60` | Metrics retention (minutes) |
|
||||||
|
| `RESOURCE_VALIDATION_INTERVAL` | `5` | Cleanup interval (minutes) |
|
||||||
|
|
||||||
|
### Master Node Settings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MASTER_RECONNECT_INTERVAL` | `5` | Reconnection base interval (seconds) |
|
||||||
|
| `MASTER_MAX_RECONNECT_ATTEMPTS` | `10` | Max reconnection attempts |
|
||||||
|
| `MASTER_PROACTIVE_INVITE_DELAY` | `500` | Delay before sending invite (ms) |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| **Port conflicts** | Check with `lsof -i :53`, use `DISABLE_DNS_SERVER=true` |
|
||||||
|
| **CA not trusted** | Import `certs/ca.cert.pem` manually (see [docs/CERTIFICATES.md](docs/CERTIFICATES.md)) |
|
||||||
|
| **Holesail failures** | Test with `holesail --live 80 --public` |
|
||||||
|
| **DNS errors** | Check logs, verify peer connections |
|
||||||
|
| **Interface issues** | Requires sudo, check with `ifconfig lo0` |
|
||||||
|
| **Ping not working (macOS)** | Disable "Stealth Mode" in Firewall settings |
|
||||||
|
| **Sync issues** | Use `--clean` to reset storage |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **P2P Exposure** - Join trusted networks only
|
||||||
|
- **CA Security** - Protect `./certs` (enables local MITM)
|
||||||
|
- **Consensus** - Monitor for claim disputes
|
||||||
|
- **Sudo** - Run in isolated environments
|
||||||
|
- **Dependencies** - Audit for vulnerabilities
|
||||||
|
|
||||||
|
## Additional Documentation
|
||||||
|
|
||||||
|
- **[Full Documentation](docs/README_LONGFORM.md)** - Comprehensive reference with all details
|
||||||
|
- **[Plugin System](docs/plugins/README.md)** - Creating and managing plugins
|
||||||
|
- **[Plugin SDK](docs/plugins/PLUGIN_SDK.md)** - API reference for plugins
|
||||||
|
- **[REST API](docs/RESTAPI.md)** - Complete API documentation
|
||||||
|
- **[Consensus](docs/CONSENSUS.md)** - Domain voting and resolution
|
||||||
|
- **[HyperDB](docs/plugins/HYPERDB.md)** - Database operations
|
||||||
|
- **[Plugin Channels](docs/plugins/PLUGIN_CHANNELS.md)** - P2P communication
|
||||||
|
- **[Hyperdrive](docs/plugins/HYPERDRIVE.md)** - Distributed file system
|
||||||
|
- **[Proxy Server](proxy-server/README.md)** - Standalone proxy configuration
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
https://p2ns.space/community
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# Storage and File Paths
|
||||||
|
# ============================================================================
|
||||||
|
STORAGE_DIR=./my-storage
|
||||||
|
DOMAINS_FILE=cache/domains.json
|
||||||
|
LOCAL_DNS_FILE=cache/local_dns.json
|
||||||
|
HOLESAIL_SERVERS_FILE=cache/holesail_servers.json
|
||||||
|
HOLESAIL_CLIENTS_FILE=cache/holesail_clients.json
|
||||||
|
SELECTOR_CACHE_FILE=cache/selector_cache.json
|
||||||
|
SUBSCRIPTIONS_FILE=cache/subscriptions.json
|
||||||
|
PEER_HISTORY_FILE=cache/peer_history.json
|
||||||
|
CERTS_DIR=./certs
|
||||||
|
BACKUP_DIR=./backups
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Network Configuration
|
||||||
|
# ============================================================================
|
||||||
|
# Ports
|
||||||
|
DNS_PORT=53
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
INTERNAL_PORT=8080
|
||||||
|
PORT_CHECK_TIMEOUT=2
|
||||||
|
|
||||||
|
# Hyperswarm Configuration
|
||||||
|
# Maximum number of peer connections (default: 24)
|
||||||
|
MAX_PEERS=24
|
||||||
|
# Connection timeout in milliseconds for swarm connections (default: 30000 = 30 seconds)
|
||||||
|
# Increase this value if local network peers are timing out
|
||||||
|
SWARM_CONNECTION_TIMEOUT=30000
|
||||||
|
# Keep-alive interval in milliseconds to rejoin swarm topic (default: 60000 = 60 seconds)
|
||||||
|
# This ensures the node stays connected to the swarm/DHT network
|
||||||
|
# Set to 0 to disable keep-alive (not recommended for master nodes)
|
||||||
|
SWARM_KEEPALIVE_INTERVAL=60000
|
||||||
|
# Channel keep-alive interval in milliseconds for plugin channels (default: 30000 = 30 seconds)
|
||||||
|
# Sends heartbeat pings to detect stale channels and trigger reconnection
|
||||||
|
# Set to 0 to disable channel keep-alive
|
||||||
|
CHANNEL_KEEPALIVE_INTERVAL=30000
|
||||||
|
# Channel keep-alive timeout in milliseconds (default: 10000 = 10 seconds)
|
||||||
|
# If no pong is received within this time, the channel is marked stale and recreated
|
||||||
|
CHANNEL_KEEPALIVE_TIMEOUT=10000
|
||||||
|
|
||||||
|
# DNS
|
||||||
|
# Public DNS server(s) - supports comma-separated list for failover
|
||||||
|
# Example: PUBLIC_DNS_SERVER=1.1.1.1,8.8.8.8,9.9.9.9
|
||||||
|
PUBLIC_DNS_SERVER=1.1.1.1
|
||||||
|
# Note: Internal domains are now automatically discovered from plugin-sites/{domain}/config.json files
|
||||||
|
# p2ns.admin is always treated as an internal domain
|
||||||
|
|
||||||
|
# Subnets (Virtual Interface IP Allocation)
|
||||||
|
SUBNET_BASE=192.168.3.
|
||||||
|
INITIAL_IP_INDEX=2
|
||||||
|
SUBNET_NAME=
|
||||||
|
SUBNETS=[{"base":"192.168.3.0","cidr":24,"startIndex":2,"name":"Primary Subnet"},{"base":"10.0.0.0","cidr":24,"startIndex":2,"name":"Secondary Subnet"}]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Holesail Configuration
|
||||||
|
# ============================================================================
|
||||||
|
HOLESAIL_TIMEOUT=5
|
||||||
|
FULL_PERSISTENCE=false
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Consensus Configuration
|
||||||
|
# ============================================================================
|
||||||
|
CONSENSUS_QUORUM_THRESHOLD=0.5
|
||||||
|
CONSENSUS_MIN_VOTES=2
|
||||||
|
CONSENSUS_TIE_BREAKER=timestamp
|
||||||
|
CONSENSUS_VOTE_VALIDATION=true
|
||||||
|
CONSENSUS_IMMEDIATE_UPDATE=true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Feature Flags
|
||||||
|
# ============================================================================
|
||||||
|
DISABLE_DNS_SERVER=false
|
||||||
|
DISABLE_PROXY_SERVER=false
|
||||||
|
DISABLE_VIRTUAL_INTERFACES=false
|
||||||
|
ALLOW_ANY_WRITER_INVITES=true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Logging
|
||||||
|
# ============================================================================
|
||||||
|
LOG_LEVEL=1
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Backup Configuration
|
||||||
|
# ============================================================================
|
||||||
|
BACKUP_RETENTION=25
|
||||||
|
BACKUP_INTERVAL=720
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Metrics Configuration
|
||||||
|
# ============================================================================
|
||||||
|
METRICS_RETENTION_MS=60
|
||||||
|
METRICS_SAMPLING_RATE=1.0
|
||||||
|
METRICS_AGGREGATION_INTERVAL=60
|
||||||
|
METRICS_MAX_BUFFER_SIZE=1000
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# P2P Network Configuration
|
||||||
|
# ============================================================================
|
||||||
|
TOPIC_SEED=p2ns-dns
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Master Node Configuration
|
||||||
|
# ============================================================================
|
||||||
|
# These settings only apply when running with --master flag
|
||||||
|
# Base interval in seconds for reconnection attempts when peers disconnect (default: 5)
|
||||||
|
# Master nodes will attempt to reconnect to disconnected peers with exponential backoff
|
||||||
|
MASTER_RECONNECT_INTERVAL=5
|
||||||
|
# Maximum number of reconnection attempts per peer before giving up (default: 10)
|
||||||
|
MASTER_MAX_RECONNECT_ATTEMPTS=10
|
||||||
|
# Delay in milliseconds before sending proactive invite to new peers (default: 500)
|
||||||
|
# Master nodes automatically send invites to peers when they connect
|
||||||
|
MASTER_PROACTIVE_INVITE_DELAY=500
|
||||||
|
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
# P2NS Architecture
|
||||||
|
|
||||||
|
This document describes the internal architecture of P2NS, including module organization, data flow, and key implementation details.
|
||||||
|
|
||||||
|
## Module Organization
|
||||||
|
|
||||||
|
P2NS is organized into several module categories under the `includes/` directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
includes/
|
||||||
|
├── admin/ # Admin interface and API
|
||||||
|
├── core/ # Core P2NS functionality
|
||||||
|
├── infrastructure/ # System utilities and patterns
|
||||||
|
├── maintenance/ # Cleanup, backup, and resource management
|
||||||
|
├── networking/ # DNS, proxying, and tunneling
|
||||||
|
├── plugins/ # Plugin system and SDK
|
||||||
|
└── security/ # Certificate management
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Modules
|
||||||
|
|
||||||
|
### `core/core.js`
|
||||||
|
Manages the Autopass (decentralized ledger) for domain claims and votes. Handles:
|
||||||
|
- Domain claim creation (`claim:domain:claimant`)
|
||||||
|
- Vote management (`vote:domain:claimant:voter`)
|
||||||
|
- Consensus calculation and caching
|
||||||
|
- Auto-voting logic
|
||||||
|
|
||||||
|
### `core/domains.js`
|
||||||
|
Domain management including:
|
||||||
|
- Adding/removing domains
|
||||||
|
- Loading domains from `domains.json`
|
||||||
|
- File watching for hot reloading
|
||||||
|
- Internal domain detection
|
||||||
|
|
||||||
|
### `core/domain_cleanup.js`
|
||||||
|
Comprehensive domain removal including:
|
||||||
|
- P2P network claim removal
|
||||||
|
- Holesail client cleanup
|
||||||
|
- Virtual interface removal
|
||||||
|
- DNS preference cleanup
|
||||||
|
|
||||||
|
## Infrastructure Modules
|
||||||
|
|
||||||
|
### `infrastructure/logger.js`
|
||||||
|
Leveled logging system with prefixes:
|
||||||
|
- `logDebug(prefix, message)` - DEBUG level (0)
|
||||||
|
- `logInfo(prefix, message)` - INFO level (1)
|
||||||
|
- `logWarn(prefix, message)` - WARN level (2)
|
||||||
|
- `logError(prefix, message)` - ERROR level (3)
|
||||||
|
|
||||||
|
Logs are broadcast to WebSocket clients for real-time admin interface updates.
|
||||||
|
|
||||||
|
### `infrastructure/state.js`
|
||||||
|
Global state management including:
|
||||||
|
- Domain-to-IP mappings
|
||||||
|
- Holesail connections
|
||||||
|
- TLS/HTTP servers
|
||||||
|
- Peer channels and metrics
|
||||||
|
- Version preferences
|
||||||
|
- DNS pass instance
|
||||||
|
|
||||||
|
### `infrastructure/config.js`
|
||||||
|
Configuration validation on startup:
|
||||||
|
- Environment variable parsing
|
||||||
|
- Default value handling
|
||||||
|
- Type validation
|
||||||
|
|
||||||
|
### `infrastructure/validation.js`
|
||||||
|
Input validation utilities:
|
||||||
|
- Domain name validation
|
||||||
|
- Holesail hash validation
|
||||||
|
- IP address validation
|
||||||
|
- Port validation
|
||||||
|
|
||||||
|
### `infrastructure/circuit_breaker.js`
|
||||||
|
Circuit breaker pattern implementation for preventing cascading failures:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { getCircuitBreaker } = require('./circuit_breaker');
|
||||||
|
|
||||||
|
const breaker = getCircuitBreaker('dns-service', {
|
||||||
|
failureThreshold: 5, // Failures before opening
|
||||||
|
resetTimeout: 60000, // Time before half-open
|
||||||
|
monitoringWindow: 60000 // Window for counting failures
|
||||||
|
});
|
||||||
|
|
||||||
|
// States: CLOSED -> OPEN -> HALF_OPEN -> CLOSED
|
||||||
|
await breaker.execute(async () => {
|
||||||
|
// Protected operation
|
||||||
|
}, 'dns-query');
|
||||||
|
```
|
||||||
|
|
||||||
|
### `infrastructure/rate_limit.js`
|
||||||
|
In-memory rate limiting for API endpoints:
|
||||||
|
- Configurable requests per window
|
||||||
|
- Per-IP tracking
|
||||||
|
- Automatic cleanup
|
||||||
|
- Local IP exemption
|
||||||
|
- Endpoint exemption (GET requests, health checks)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { checkRateLimit } = require('./rate_limit');
|
||||||
|
|
||||||
|
const result = checkRateLimit(req);
|
||||||
|
if (result) {
|
||||||
|
// Rate limited - return 429 response
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `infrastructure/error_handler.js`
|
||||||
|
User-friendly error handling:
|
||||||
|
- Error code translation (EADDRINUSE, ENOENT, etc.)
|
||||||
|
- Production-safe error messages
|
||||||
|
- Error response formatting
|
||||||
|
|
||||||
|
### `infrastructure/async_errors.js`
|
||||||
|
Async error handling utilities:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { wrapAsync, safePromise, retryWithBackoff } = require('./async_errors');
|
||||||
|
|
||||||
|
// Wrap async function with error logging
|
||||||
|
const safeFn = wrapAsync(asyncFn, 'context-name');
|
||||||
|
|
||||||
|
// Execute promise without throwing
|
||||||
|
const { success, result, error } = await safePromise(promise, 'context');
|
||||||
|
|
||||||
|
// Retry with exponential backoff
|
||||||
|
const result = await retryWithBackoff(asyncFn, {
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 1000,
|
||||||
|
maxDelay: 10000
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### `infrastructure/utils.js`
|
||||||
|
Common utility functions:
|
||||||
|
- Time conversion helpers
|
||||||
|
- String manipulation
|
||||||
|
- Object utilities
|
||||||
|
|
||||||
|
## Networking Modules
|
||||||
|
|
||||||
|
### `networking/dns.js`
|
||||||
|
UDP DNS server implementation:
|
||||||
|
- P2P domain resolution
|
||||||
|
- Local DNS record lookup
|
||||||
|
- Public DNS fallback
|
||||||
|
- DNS conflict handling
|
||||||
|
|
||||||
|
### `networking/dns_pool.js`
|
||||||
|
DNS resolver connection pooling:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { dnsPool } = require('./dns_pool');
|
||||||
|
|
||||||
|
// Query with automatic failover
|
||||||
|
const response = await dnsPool.query({
|
||||||
|
type: 'query',
|
||||||
|
questions: [{ name: 'example.com', type: 'A' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Supports multiple DNS servers via PUBLIC_DNS_SERVER env var
|
||||||
|
// Example: PUBLIC_DNS_SERVER=1.1.1.1,8.8.8.8,9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Connection pooling (default 5 connections)
|
||||||
|
- Round-robin query distribution
|
||||||
|
- Automatic failover between DNS servers
|
||||||
|
- Query timeout handling (5 seconds)
|
||||||
|
- Proper listener cleanup
|
||||||
|
|
||||||
|
### `networking/holesail.js`
|
||||||
|
Holesail server/client management:
|
||||||
|
- Server creation and lifecycle
|
||||||
|
- Client creation with lazy initialization
|
||||||
|
- Connection persistence
|
||||||
|
- Configuration file management
|
||||||
|
|
||||||
|
### `networking/holesail_child.js`
|
||||||
|
Child process for Holesail instances:
|
||||||
|
- IPC communication with parent
|
||||||
|
- Console log redirection
|
||||||
|
- Port availability checking
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### `networking/internal_domains_proxy.js`
|
||||||
|
HTTPS proxy for internal domains:
|
||||||
|
- Plugin request routing
|
||||||
|
- Static file serving
|
||||||
|
- WebSocket upgrade handling
|
||||||
|
- TLS termination
|
||||||
|
|
||||||
|
### `networking/p2p_domains_proxy.js`
|
||||||
|
HTTPS proxy for P2P domains:
|
||||||
|
- Holesail client management
|
||||||
|
- Version preference handling (P2P vs public)
|
||||||
|
- SSL/TLS tunneling
|
||||||
|
- Connection timeout management
|
||||||
|
|
||||||
|
### `networking/virtual_interfaces.js`
|
||||||
|
Virtual network interface management:
|
||||||
|
- IP alias creation on loopback
|
||||||
|
- Multi-subnet support
|
||||||
|
- Cross-platform (macOS, Linux, Windows)
|
||||||
|
- Interface cleanup
|
||||||
|
|
||||||
|
## Maintenance Modules
|
||||||
|
|
||||||
|
### `maintenance/backup.js`
|
||||||
|
Backup and restore system:
|
||||||
|
- Automatic scheduled backups
|
||||||
|
- Manual backup creation
|
||||||
|
- Backup rotation
|
||||||
|
- Restore functionality
|
||||||
|
- Metadata tracking
|
||||||
|
|
||||||
|
### `maintenance/cleanup.js`
|
||||||
|
Resource cleanup on shutdown:
|
||||||
|
- Server closure
|
||||||
|
- Connection termination
|
||||||
|
- Interface removal
|
||||||
|
- Port freeing
|
||||||
|
|
||||||
|
### `maintenance/metrics.js`
|
||||||
|
System metrics collection:
|
||||||
|
- Request statistics
|
||||||
|
- Response time tracking
|
||||||
|
- Error rate monitoring
|
||||||
|
- Historical data aggregation
|
||||||
|
|
||||||
|
### `maintenance/resource_tracker.js`
|
||||||
|
Resource lifecycle management:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { resourceTracker } = require('./resource_tracker');
|
||||||
|
|
||||||
|
// Register a resource
|
||||||
|
const id = resourceTracker.register('socket', socket, () => socket.close());
|
||||||
|
|
||||||
|
// Cleanup specific resource
|
||||||
|
await resourceTracker.cleanup(id);
|
||||||
|
|
||||||
|
// Cleanup all resources
|
||||||
|
await resourceTracker.cleanupAll();
|
||||||
|
|
||||||
|
// Get resource counts
|
||||||
|
const counts = resourceTracker.getCounts();
|
||||||
|
// { socket: 5, timer: 3, server: 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `maintenance/resource_validation.js`
|
||||||
|
Periodic resource validation:
|
||||||
|
- Stale connection detection
|
||||||
|
- Orphaned server cleanup
|
||||||
|
- State map consistency checks
|
||||||
|
- Configurable validation interval
|
||||||
|
|
||||||
|
## Plugin Modules
|
||||||
|
|
||||||
|
### `plugins/plugin-handler.js`
|
||||||
|
Plugin lifecycle management:
|
||||||
|
- Plugin discovery from `plugin-sites/`
|
||||||
|
- Loading/unloading
|
||||||
|
- Request routing
|
||||||
|
- WebSocket registration
|
||||||
|
|
||||||
|
### `plugins/sdk.js`
|
||||||
|
Plugin SDK providing access to:
|
||||||
|
- State (`sdk.state`)
|
||||||
|
- DNS operations (`sdk.dns`)
|
||||||
|
- Domain management (`sdk.domains`)
|
||||||
|
- Holesail operations (`sdk.holesail`)
|
||||||
|
- Peer management (`sdk.peers`)
|
||||||
|
- Certificate management (`sdk.certificates`)
|
||||||
|
- Local DNS (`sdk.localDns`)
|
||||||
|
- Interfaces (`sdk.interfaces`)
|
||||||
|
- Metrics (`sdk.metrics`)
|
||||||
|
- WebSocket (`sdk.websocket`)
|
||||||
|
- Configuration (`sdk.config`)
|
||||||
|
- Backup (`sdk.backup`)
|
||||||
|
- Subscriptions (`sdk.subscriptions`)
|
||||||
|
- HTTP client (`sdk.http`)
|
||||||
|
- File system (`sdk.fs`)
|
||||||
|
- Events (`sdk.events`)
|
||||||
|
- Utilities (`sdk.utils`)
|
||||||
|
- Security (`sdk.security`)
|
||||||
|
- Logging (`sdk.log`)
|
||||||
|
- Router (`sdk.router`)
|
||||||
|
- Admin panel (`sdk.admin`)
|
||||||
|
- Database (`sdk.db`)
|
||||||
|
- Channels (`sdk.channels`)
|
||||||
|
- Drives (`sdk.drives`)
|
||||||
|
- Authentication (`sdk.auth`)
|
||||||
|
- Profiles (`sdk.profiles`)
|
||||||
|
|
||||||
|
### `plugins/db-manager.js`
|
||||||
|
HyperDB database management for plugins:
|
||||||
|
- Schema building
|
||||||
|
- Database initialization
|
||||||
|
- Replication management
|
||||||
|
|
||||||
|
### `plugins/drive-manager.js`
|
||||||
|
Hyperdrive file system management:
|
||||||
|
- Drive creation and caching
|
||||||
|
- File operations
|
||||||
|
- Replication
|
||||||
|
|
||||||
|
### `plugins/channel-manager.js`
|
||||||
|
Protomux channel management:
|
||||||
|
- Channel creation
|
||||||
|
- Message routing
|
||||||
|
- Peer tracking
|
||||||
|
|
||||||
|
### `plugins/replication-manager.js`
|
||||||
|
Database replication over Hyperswarm:
|
||||||
|
- Global topic replication
|
||||||
|
- Peer connection handling
|
||||||
|
- Core synchronization
|
||||||
|
|
||||||
|
### `plugins/hyperdb-builder.js`
|
||||||
|
HyperDB schema builder:
|
||||||
|
- Schema generation from config.json
|
||||||
|
- Helper function loading
|
||||||
|
- Spec file generation
|
||||||
|
|
||||||
|
### `plugins/auth-utils.js`
|
||||||
|
Authentication utilities for plugins:
|
||||||
|
- Ed25519 token generation
|
||||||
|
- Token verification
|
||||||
|
- Request authentication
|
||||||
|
|
||||||
|
## Security Modules
|
||||||
|
|
||||||
|
### `security/certificate_authority.js`
|
||||||
|
TLS certificate management:
|
||||||
|
- Root CA generation
|
||||||
|
- Domain certificate creation
|
||||||
|
- Certificate installation (macOS, Linux, Windows)
|
||||||
|
- Expiration monitoring
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### DNS Resolution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Request
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DNS Server (port 53)
|
||||||
|
│
|
||||||
|
├─► Local DNS Records (cache/local_dns.json)
|
||||||
|
│ │
|
||||||
|
│ └─► Return if found
|
||||||
|
│
|
||||||
|
├─► P2P Domain Check
|
||||||
|
│ │
|
||||||
|
│ ├─► Consensus Resolution
|
||||||
|
│ │ │
|
||||||
|
│ │ └─► Return internal IP
|
||||||
|
│ │
|
||||||
|
│ └─► Start Holesail Client (if needed)
|
||||||
|
│
|
||||||
|
└─► Public DNS Fallback (dns_pool.js)
|
||||||
|
│
|
||||||
|
└─► Return public IP
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proxy Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTPS Request (port 443)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SNI Extraction
|
||||||
|
│
|
||||||
|
├─► Internal Domain
|
||||||
|
│ │
|
||||||
|
│ └─► Plugin Handler
|
||||||
|
│ │
|
||||||
|
│ ├─► API Endpoint
|
||||||
|
│ │
|
||||||
|
│ └─► Static Files
|
||||||
|
│
|
||||||
|
└─► P2P Domain
|
||||||
|
│
|
||||||
|
├─► Version Preference Check
|
||||||
|
│ │
|
||||||
|
│ ├─► P2P Mode
|
||||||
|
│ │ │
|
||||||
|
│ │ └─► Holesail Tunnel
|
||||||
|
│ │
|
||||||
|
│ └─► Public Mode
|
||||||
|
│ │
|
||||||
|
│ └─► Direct Connection
|
||||||
|
│
|
||||||
|
└─► Proxy to Target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin Request Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Plugin Request
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Plugin Handler
|
||||||
|
│
|
||||||
|
├─► Static File Check (www/)
|
||||||
|
│ │
|
||||||
|
│ └─► Serve if exists
|
||||||
|
│
|
||||||
|
└─► handler() Function
|
||||||
|
│
|
||||||
|
├─► API Routes
|
||||||
|
│
|
||||||
|
├─► WebSocket Upgrade
|
||||||
|
│
|
||||||
|
└─► Return false (404)
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
Global state is managed in `infrastructure/state.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// Domain mappings
|
||||||
|
domainToIP: new Map(), // domain -> IP
|
||||||
|
holesails: new Map(), // domain -> Holesail instance
|
||||||
|
|
||||||
|
// Server instances
|
||||||
|
tlsServers: new Map(), // domain -> TLS server
|
||||||
|
httpServers: new Map(), // domain -> HTTP server
|
||||||
|
|
||||||
|
// Peer management
|
||||||
|
peerChannels: new Map(), // peerId -> channel
|
||||||
|
peerMetrics: new Map(), // peerId -> metrics
|
||||||
|
peerHistory: new Map(), // peerId -> history
|
||||||
|
blockedPeers: new Set(), // blocked peer IDs
|
||||||
|
|
||||||
|
// DNS
|
||||||
|
dnsPass: null, // Autopass instance
|
||||||
|
versionPreferences: {}, // domain -> 'p2p' | 'public'
|
||||||
|
publicIpForDomain: {}, // domain -> public IP
|
||||||
|
domainsWithBoth: new Set(), // domains with P2P + public
|
||||||
|
|
||||||
|
// Timeouts
|
||||||
|
holesailClientTimeouts: new Map(), // domain -> timeout ID
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
subnets: [], // Subnet configurations
|
||||||
|
currentSubnetIndex: 0, // Current subnet for allocation
|
||||||
|
currentIPIndex: 2, // Current IP index in subnet
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Strategy
|
||||||
|
|
||||||
|
1. **Async Errors**: Use `wrapAsync()` or `safePromise()` for async operations
|
||||||
|
2. **Circuit Breaker**: Protect external service calls
|
||||||
|
3. **Rate Limiting**: Prevent abuse of API endpoints
|
||||||
|
4. **Graceful Degradation**: Fall back to alternative services
|
||||||
|
5. **Retry Logic**: Use `retryWithBackoff()` for transient failures
|
||||||
|
6. **Resource Cleanup**: Track and cleanup resources on shutdown
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables are loaded from `.env` and validated on startup. Key categories:
|
||||||
|
|
||||||
|
- **Storage**: `STORAGE_DIR`, `DOMAINS_FILE`, `LOCAL_DNS_FILE`
|
||||||
|
- **Networking**: `DNS_PORT`, `HTTPS_PORT`, `HTTP_PORT`, `PUBLIC_DNS_SERVER`
|
||||||
|
- **Holesail**: `INTERNAL_PORT`, `HOLESAIL_TIMEOUT`
|
||||||
|
- **Consensus**: `CONSENSUS_QUORUM_THRESHOLD`, `CONSENSUS_MIN_VOTES`
|
||||||
|
- **Backup**: `BACKUP_DIR`, `BACKUP_RETENTION`, `BACKUP_INTERVAL`
|
||||||
|
- **Metrics**: `METRICS_RETENTION_MS`, `METRICS_SAMPLING_RATE`
|
||||||
|
- **Rate Limiting**: `RATE_LIMIT_MAX_REQUESTS`, `RATE_LIMIT_WINDOW_MS`
|
||||||
|
|
||||||
|
See the main [README.md](../README.md#environment-variables) for complete configuration reference.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Main README](../README.md) - Overview and usage
|
||||||
|
- [Plugin System](plugins/README.md) - Plugin development guide
|
||||||
|
- [Plugin SDK](plugins/PLUGIN_SDK.md) - Plugin SDK API reference
|
||||||
|
- [REST API](RESTAPI.md) - API endpoint documentation
|
||||||
|
- [HyperDB](plugins/HYPERDB.md) - Database integration
|
||||||
|
- [Hyperdrive](plugins/HYPERDRIVE.md) - Distributed file system
|
||||||
|
- [Plugin Channels](plugins/PLUGIN_CHANNELS.md) - P2P communication
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# P2NS Certificate Management
|
||||||
|
|
||||||
|
P2NS generates and manages TLS certificates for secure HTTPS connections to P2P and internal domains.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- **Root CA**: Generated at `certs/ca.cert.pem`, used to sign all domain certificates
|
||||||
|
- **Domain Certs**: Created in `certs/<domain>/` with Subject Alternative Names (SANs)
|
||||||
|
- **SNI Support**: Dynamic certificate selection based on requested hostname
|
||||||
|
- **Auto-Install**: CA is automatically installed on supported systems
|
||||||
|
|
||||||
|
## Automatic Installation
|
||||||
|
|
||||||
|
On startup, P2NS attempts to install the root CA:
|
||||||
|
|
||||||
|
| Platform | Location | Method |
|
||||||
|
|----------|----------|--------|
|
||||||
|
| macOS | System Keychain | `security add-trusted-cert` |
|
||||||
|
| Linux | `/usr/local/share/ca-certificates` | `update-ca-certificates` |
|
||||||
|
| Windows | Root certificate store | `certutil -addstore` |
|
||||||
|
|
||||||
|
## Manual Root CA Installation
|
||||||
|
|
||||||
|
If automatic installation fails, install the certificate manually.
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
**Via Keychain Access:**
|
||||||
|
1. Open **Keychain Access** (Applications > Utilities)
|
||||||
|
2. Select **System** keychain
|
||||||
|
3. Go to **File** > **Import Items...**
|
||||||
|
4. Navigate to `./certs/ca.cert.pem`
|
||||||
|
5. Find "P2NS CA", double-click it
|
||||||
|
6. Expand **Trust** and set to **Always Trust**
|
||||||
|
7. Enter password when prompted
|
||||||
|
|
||||||
|
**Via Command Line:**
|
||||||
|
```bash
|
||||||
|
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./certs/ca.cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp ./certs/ca.cert.pem /usr/local/share/ca-certificates/p2ns-ca.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
Open Command Prompt as Administrator:
|
||||||
|
```cmd
|
||||||
|
cd C:\path\to\p2ns
|
||||||
|
certutil -addstore -f "ROOT" certs\ca.cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser-Specific Installation
|
||||||
|
|
||||||
|
Some browsers maintain their own certificate stores.
|
||||||
|
|
||||||
|
### Firefox
|
||||||
|
|
||||||
|
Firefox requires separate installation:
|
||||||
|
1. Open `about:preferences#privacy`
|
||||||
|
2. Scroll to **Certificates** > **View Certificates**
|
||||||
|
3. Go to **Authorities** tab
|
||||||
|
4. Click **Import...** and select `./certs/ca.cert.pem`
|
||||||
|
5. Check **Trust this CA to identify websites**
|
||||||
|
|
||||||
|
### Chrome/Edge (Linux only)
|
||||||
|
|
||||||
|
Chrome on Linux may need manual import:
|
||||||
|
1. Open `chrome://settings/security`
|
||||||
|
2. Click **Manage certificates**
|
||||||
|
3. Go to **Authorities** tab
|
||||||
|
4. Click **Import** and select `./certs/ca.cert.pem`
|
||||||
|
5. Check **Trust this certificate for identifying websites**
|
||||||
|
|
||||||
|
### Safari
|
||||||
|
|
||||||
|
Safari uses the macOS System Keychain. Follow the macOS instructions above.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After installation, verify by accessing `https://p2ns.admin` or `https://peer.directory`. You should see a valid TLS connection without certificate warnings.
|
||||||
|
|
||||||
|
## Certificate Regeneration
|
||||||
|
|
||||||
|
Via the admin interface **Certificates** tab:
|
||||||
|
- **Regenerate CA**: Creates new root CA (requires reinstallation)
|
||||||
|
- **Regenerate Domain Cert**: Creates new certificate for a specific domain
|
||||||
|
- **Delete Domain Cert**: Removes a domain's certificate
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CERTS_DIR` | `./certs` | Certificate storage directory |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Browser shows "Not Secure" | Import CA into browser (Firefox) or OS |
|
||||||
|
| CA expired | Regenerate via admin interface |
|
||||||
|
| Domain cert missing | Generate via admin Certificates tab |
|
||||||
|
| Permission denied | Run P2NS with sudo |
|
||||||
|
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
# P2NS Consensus Mechanism
|
||||||
|
|
||||||
|
This document provides a deep dive into the P2NS consensus mechanism for domain ownership resolution.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
P2NS uses a quorum-based consensus mechanism to resolve domain ownership disputes. When multiple peers claim the same domain, the network votes to determine the legitimate owner. This enables decentralized domain management without a central authority.
|
||||||
|
|
||||||
|
**Key goals:**
|
||||||
|
- Prevent domain squatting through voting
|
||||||
|
- Handle network partitions gracefully
|
||||||
|
- Resolve ties deterministically
|
||||||
|
- Support single-node operation (local claims)
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Domain claims and votes are stored in the Autopass distributed ledger using a key-value format.
|
||||||
|
|
||||||
|
### Claims
|
||||||
|
|
||||||
|
Claims assert ownership of a domain:
|
||||||
|
|
||||||
|
```
|
||||||
|
Key: claim:{domain}:{claimant}
|
||||||
|
Value: {"hash": "hs://...", "clients": [...], "timestamp": 1704067200000, "ssl": false}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `hash` | string | Holesail connection hash for the domain |
|
||||||
|
| `clients` | array | Service definitions (serviceName, key, port, protocol) |
|
||||||
|
| `timestamp` | number | Unix timestamp when claim was created |
|
||||||
|
| `ssl` | boolean | Whether the Holesail connection uses SSL/TLS |
|
||||||
|
|
||||||
|
**Legacy format:** Older claims may store just the hash string without JSON wrapper.
|
||||||
|
|
||||||
|
### Votes
|
||||||
|
|
||||||
|
Votes support a specific claimant for a domain:
|
||||||
|
|
||||||
|
```
|
||||||
|
Key: vote:{domain}:{claimant}:{voter}
|
||||||
|
Value: "1"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `domain` | The domain being voted on |
|
||||||
|
| `claimant` | Public key of the claim being supported |
|
||||||
|
| `voter` | Public key of the peer casting the vote |
|
||||||
|
|
||||||
|
## Consensus Algorithm
|
||||||
|
|
||||||
|
### Resolution Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start[Get Consensus State] --> CheckPass{dnsPass initialized?}
|
||||||
|
CheckPass -->|No| ErrorState[Return error state]
|
||||||
|
CheckPass -->|Yes| CollectClaims[Collect all claims for domain]
|
||||||
|
|
||||||
|
CollectClaims --> CheckClaims{Any claims exist?}
|
||||||
|
CheckClaims -->|No| NoClaims[Status: no_claims]
|
||||||
|
CheckClaims -->|Yes| CollectVotes[Collect and validate votes]
|
||||||
|
|
||||||
|
CollectVotes --> CalcQuorum[Calculate quorum requirement]
|
||||||
|
CalcQuorum --> CheckQuorum{Quorum met?}
|
||||||
|
|
||||||
|
CheckQuorum -->|No| CheckSingleLocal{Single local claim?}
|
||||||
|
CheckSingleLocal -->|Yes| ResolvedLocal[Status: resolved]
|
||||||
|
CheckSingleLocal -->|No| InsufficientQuorum[Status: insufficient_quorum]
|
||||||
|
|
||||||
|
CheckQuorum -->|Yes| FindWinner[Find claimant with most votes]
|
||||||
|
FindWinner --> CheckTie{Multiple winners?}
|
||||||
|
|
||||||
|
CheckTie -->|No| Resolved[Status: resolved]
|
||||||
|
CheckTie -->|Yes| TieBreaker[Apply tie-breaker strategy]
|
||||||
|
TieBreaker --> TieResolved[Status: tie - resolved via tie-breaker]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vote Collection
|
||||||
|
|
||||||
|
1. Iterate through all ledger entries
|
||||||
|
2. Filter votes matching `vote:{domain}:*`
|
||||||
|
3. Parse vote key to extract claimant and voter
|
||||||
|
4. Validate each vote (must reference existing claim)
|
||||||
|
5. Count valid votes per claimant
|
||||||
|
|
||||||
|
### Quorum Calculation
|
||||||
|
|
||||||
|
The minimum votes required for consensus:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
minVotes = max(CONSENSUS_MIN_VOTES, ceil(activePeers * CONSENSUS_QUORUM_THRESHOLD))
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
- `activePeers` = connected peers + 1 (local node)
|
||||||
|
- `CONSENSUS_QUORUM_THRESHOLD` = 0.5 (default)
|
||||||
|
- `CONSENSUS_MIN_VOTES` = 2 (default)
|
||||||
|
|
||||||
|
**Example:** With 5 active peers and default settings:
|
||||||
|
- `ceil(5 * 0.5) = 3`
|
||||||
|
- `max(2, 3) = 3` votes required
|
||||||
|
|
||||||
|
### Resolution States
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `resolved` | Single winner determined by vote count or tie-breaker |
|
||||||
|
| `insufficient_quorum` | Not enough votes to reach consensus |
|
||||||
|
| `tie` | Multiple claimants tied; resolved via tie-breaker |
|
||||||
|
| `no_claims` | No claims exist for this domain |
|
||||||
|
| `error` | System error (e.g., dnsPass not initialized) |
|
||||||
|
|
||||||
|
### Special Case: Single Local Claim
|
||||||
|
|
||||||
|
When there are no votes but only the local peer has claimed a domain, it's automatically resolved to the local claimant. This enables single-node operation without requiring external votes.
|
||||||
|
|
||||||
|
## Tie-Breaking Strategies
|
||||||
|
|
||||||
|
When multiple claimants have equal votes, a tie-breaker determines the winner.
|
||||||
|
|
||||||
|
### timestamp (Default)
|
||||||
|
|
||||||
|
First-come-first-served: the oldest claim wins.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
candidates.sort((a, b) => claimTimestamps[a] - claimTimestamps[b])[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** Rewards early adopters and prevents hostile takeovers of established domains.
|
||||||
|
|
||||||
|
### claimant_age
|
||||||
|
|
||||||
|
Prefers the local writer if they're a candidate, otherwise falls back to lexicographic ordering.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (candidates.includes(localWriter)) return localWriter;
|
||||||
|
return candidates.sort((a, b) => a.localeCompare(b))[0];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** Gives local claims priority while maintaining determinism.
|
||||||
|
|
||||||
|
### lexicographic
|
||||||
|
|
||||||
|
Prefers local writer, then sorts candidates alphabetically by public key.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (candidates.includes(localWriter)) return localWriter;
|
||||||
|
return candidates.sort((a, b) => a.localeCompare(b))[0];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** Ensures all nodes reach the same conclusion deterministically.
|
||||||
|
|
||||||
|
## Auto-Voting
|
||||||
|
|
||||||
|
P2NS automatically casts votes under certain conditions.
|
||||||
|
|
||||||
|
### When Auto-Votes Occur
|
||||||
|
|
||||||
|
1. **On startup:** After connecting to the network
|
||||||
|
2. **On peer connection:** When new peers join
|
||||||
|
3. **On claim discovery:** When new claims are replicated
|
||||||
|
4. **On manual trigger:** Via `/api/consensus/recalculate`
|
||||||
|
|
||||||
|
### Auto-Vote Logic
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function autoVoteForDomain(domain, entries) {
|
||||||
|
// Get claims for this domain
|
||||||
|
const claims = getClaimsForDomain(domain, entries);
|
||||||
|
|
||||||
|
// Check if local peer already voted
|
||||||
|
if (hasLocalVote(domain, entries)) return;
|
||||||
|
|
||||||
|
// Vote for local claim if exists
|
||||||
|
if (claims.has(localWriter)) {
|
||||||
|
await castVote(domain, localWriter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vote for claim with most existing votes
|
||||||
|
const winner = getLeadingClaimant(domain, entries);
|
||||||
|
if (winner) {
|
||||||
|
await castVote(domain, winner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vote Validation
|
||||||
|
|
||||||
|
Votes must reference an existing claim to be counted:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function validateVote(claimant, claims) {
|
||||||
|
if (!CONSENSUS_VOTE_VALIDATION) return true;
|
||||||
|
|
||||||
|
if (!claims.hasOwnProperty(claimant)) {
|
||||||
|
consensusMetrics.validationFailures++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Invalid votes are logged and excluded from vote counts.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure consensus behavior via environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CONSENSUS_QUORUM_THRESHOLD` | `0.5` | Percentage of peers required (0.0-1.0) |
|
||||||
|
| `CONSENSUS_MIN_VOTES` | `2` | Minimum votes regardless of peer count |
|
||||||
|
| `CONSENSUS_TIE_BREAKER` | `timestamp` | Strategy: `timestamp`, `claimant_age`, `lexicographic` |
|
||||||
|
| `CONSENSUS_VOTE_VALIDATION` | `true` | Validate votes reference existing claims |
|
||||||
|
| `CONSENSUS_IMMEDIATE_UPDATE` | `true` | Update resolution immediately on new votes |
|
||||||
|
|
||||||
|
### Tuning Recommendations
|
||||||
|
|
||||||
|
**High-security deployments:**
|
||||||
|
```env
|
||||||
|
CONSENSUS_QUORUM_THRESHOLD=0.67
|
||||||
|
CONSENSUS_MIN_VOTES=3
|
||||||
|
CONSENSUS_VOTE_VALIDATION=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Small networks (2-3 peers):**
|
||||||
|
```env
|
||||||
|
CONSENSUS_QUORUM_THRESHOLD=0.5
|
||||||
|
CONSENSUS_MIN_VOTES=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Single-node operation:**
|
||||||
|
```env
|
||||||
|
CONSENSUS_MIN_VOTES=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
Consensus uses two-level caching for performance.
|
||||||
|
|
||||||
|
### Entries Cache
|
||||||
|
|
||||||
|
All ledger entries are cached to avoid repeated expensive lookups:
|
||||||
|
|
||||||
|
- **TTL:** 5 seconds
|
||||||
|
- **Scope:** All entries in Autopass
|
||||||
|
- **Invalidation:** On write operations, manual invalidation
|
||||||
|
|
||||||
|
### Consensus State Cache
|
||||||
|
|
||||||
|
Per-domain consensus results are cached:
|
||||||
|
|
||||||
|
- **TTL:** 10 seconds
|
||||||
|
- **Scope:** Individual domain consensus state
|
||||||
|
- **Invalidation:** On cache expiry, manual recalculation
|
||||||
|
|
||||||
|
### Cache Invalidation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function invalidateEntriesCache() {
|
||||||
|
entriesCache = null;
|
||||||
|
entriesCacheTimestamp = 0;
|
||||||
|
consensusStateCache.clear();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Called after:
|
||||||
|
- Adding/removing claims
|
||||||
|
- Casting votes
|
||||||
|
- Manual recalculation via API
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
Consensus operations are tracked for monitoring:
|
||||||
|
|
||||||
|
| Metric | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `resolutions` | Total successful domain resolutions |
|
||||||
|
| `quorumFailures` | Times quorum was not met |
|
||||||
|
| `ties` | Ties requiring tie-breaker |
|
||||||
|
| `validationFailures` | Invalid votes rejected |
|
||||||
|
| `totalVotes` | Total votes cast across all domains |
|
||||||
|
| `avgVotesPerDomain` | Average votes per domain |
|
||||||
|
| `domainResolutions` | Per-domain resolution/failure counts |
|
||||||
|
|
||||||
|
### Accessing Metrics
|
||||||
|
|
||||||
|
**Via API:**
|
||||||
|
```bash
|
||||||
|
curl https://p2ns.admin/api/consensus/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
**Via SDK:**
|
||||||
|
```javascript
|
||||||
|
const metrics = sdk.dns.getConsensusMetrics();
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Get Consensus State
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/consensus/{domain}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns current consensus state for a domain including vote counts, quorum status, and resolved claimant.
|
||||||
|
|
||||||
|
### Get Metrics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/consensus/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns aggregate consensus metrics across all domains.
|
||||||
|
|
||||||
|
### Force Recalculation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/consensus/recalculate
|
||||||
|
POST /api/consensus/recalculate/{domain}
|
||||||
|
```
|
||||||
|
|
||||||
|
Invalidates cache and triggers fresh consensus calculation. Useful after network changes or debugging.
|
||||||
|
|
||||||
|
## Example Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Single Owner
|
||||||
|
|
||||||
|
```
|
||||||
|
Peers: A (local)
|
||||||
|
Claims: A claims example.tld
|
||||||
|
Votes: (none)
|
||||||
|
|
||||||
|
Result: resolved -> A
|
||||||
|
Reason: Single local claim, no quorum needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Clear Winner
|
||||||
|
|
||||||
|
```
|
||||||
|
Peers: A, B, C (3 total)
|
||||||
|
Claims: A claims example.tld, B claims example.tld
|
||||||
|
Votes: A votes for A, B votes for A, C votes for A
|
||||||
|
|
||||||
|
Quorum: max(2, ceil(3 * 0.5)) = 2
|
||||||
|
Total votes for A: 3
|
||||||
|
|
||||||
|
Result: resolved -> A
|
||||||
|
Reason: A has majority votes, quorum met
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Tie with Timestamp Resolution
|
||||||
|
|
||||||
|
```
|
||||||
|
Peers: A, B, C, D (4 total)
|
||||||
|
Claims:
|
||||||
|
- A claims example.tld at T=1000
|
||||||
|
- B claims example.tld at T=2000
|
||||||
|
Votes: A->A, B->B, C->A, D->B
|
||||||
|
|
||||||
|
Vote counts: A=2, B=2 (tie)
|
||||||
|
Tie-breaker: timestamp
|
||||||
|
Winner: A (older claim)
|
||||||
|
|
||||||
|
Result: tie -> A
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 4: Insufficient Quorum
|
||||||
|
|
||||||
|
```
|
||||||
|
Peers: A, B, C, D, E (5 total)
|
||||||
|
Claims: A claims example.tld
|
||||||
|
Votes: A votes for A
|
||||||
|
|
||||||
|
Quorum: max(2, ceil(5 * 0.5)) = 3
|
||||||
|
Total votes: 1
|
||||||
|
|
||||||
|
Result: insufficient_quorum
|
||||||
|
Reason: Only 1 vote, need 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Domain Not Resolving
|
||||||
|
|
||||||
|
1. Check if claim exists: `GET /api/entries`
|
||||||
|
2. Check consensus state: `GET /api/consensus/{domain}`
|
||||||
|
3. Verify quorum: Are enough peers connected?
|
||||||
|
4. Force recalculation: `POST /api/consensus/recalculate/{domain}`
|
||||||
|
|
||||||
|
### Unexpected Winner
|
||||||
|
|
||||||
|
1. Check vote counts in consensus state
|
||||||
|
2. Verify tie-breaker strategy matches expectations
|
||||||
|
3. Check claim timestamps if using `timestamp` strategy
|
||||||
|
4. Review vote validation failures in metrics
|
||||||
|
|
||||||
|
### High Quorum Failures
|
||||||
|
|
||||||
|
1. Check peer count: `GET /api/peers`
|
||||||
|
2. Lower `CONSENSUS_MIN_VOTES` for small networks
|
||||||
|
3. Adjust `CONSENSUS_QUORUM_THRESHOLD` if needed
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [GLOSSARY.md](GLOSSARY.md) - Terms and concepts
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture
|
||||||
|
- [RESTAPI.md](RESTAPI.md) - API documentation
|
||||||
|
- [README.md](../README.md) - Main documentation
|
||||||
|
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
# P2NS API Usage Examples
|
||||||
|
|
||||||
|
This document provides practical examples for using the P2NS Admin API.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Basic Operations](#basic-operations)
|
||||||
|
- [Domain Management](#domain-management)
|
||||||
|
- [Holesail Management](#holesail-management)
|
||||||
|
- [Local DNS Records](#local-dns-records)
|
||||||
|
- [Certificate Management](#certificate-management)
|
||||||
|
- [WebSocket Examples](#websocket-examples)
|
||||||
|
- [Error Handling](#error-handling)
|
||||||
|
|
||||||
|
## Basic Operations
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Liveness probe
|
||||||
|
curl -k https://p2ns.admin/api/health
|
||||||
|
|
||||||
|
# Readiness probe
|
||||||
|
curl -k https://p2ns.admin/api/health?probe=readiness
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get System Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Metrics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Domain Management
|
||||||
|
|
||||||
|
### Add a Domain
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/add-domain \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.tld",
|
||||||
|
"hash": "hs://s00084bf87dfa89a3048fb081c0e6207eb5a",
|
||||||
|
"ssl": false
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### List All Domains
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/resolved-domains
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove a Domain
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/remove-domain \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.tld"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Holesail Management
|
||||||
|
|
||||||
|
### Create a Holesail Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/holesail-create \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "my-server",
|
||||||
|
"port": 8080,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"secure": true,
|
||||||
|
"udp": false,
|
||||||
|
"log": 1,
|
||||||
|
"domain": "example.tld"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Holesail Servers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/holesail-servers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart a Holesail Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/holesail-restart \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"id": "abc123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a Holesail Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/holesail-client-create \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.tld",
|
||||||
|
"key": "hs://s00084bf87dfa89a3048fb081c0e6207eb5a",
|
||||||
|
"port": 8080,
|
||||||
|
"protocol": "tcp"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local DNS Records
|
||||||
|
|
||||||
|
### Add an A Record
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/add-local-dns \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "local.example",
|
||||||
|
"type": "A",
|
||||||
|
"ttl": 3600,
|
||||||
|
"data": "192.168.1.100"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add an MX Record
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/add-local-dns \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "mail.example",
|
||||||
|
"type": "MX",
|
||||||
|
"ttl": 3600,
|
||||||
|
"preference": 10,
|
||||||
|
"exchange": "mx.example.com"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add an SRV Record
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/add-local-dns \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "_service._tcp.example",
|
||||||
|
"type": "SRV",
|
||||||
|
"ttl": 3600,
|
||||||
|
"priority": 0,
|
||||||
|
"weight": 5,
|
||||||
|
"port": 8080,
|
||||||
|
"target": "server.example.com"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update a DNS Record
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/update-local-dns \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"index": 0,
|
||||||
|
"record": {
|
||||||
|
"name": "local.example",
|
||||||
|
"type": "A",
|
||||||
|
"ttl": 7200,
|
||||||
|
"data": "192.168.1.101"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a DNS Record
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/delete-local-dns \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"index": 0
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Certificate Management
|
||||||
|
|
||||||
|
### Generate a Certificate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/generate-cert \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.tld"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Certificate Details
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k "https://p2ns.admin/api/cert-details?domain=example.tld"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regenerate Certificate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/regenerate-cert \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.tld"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regenerate Root CA
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/regenerate-ca
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket Examples
|
||||||
|
|
||||||
|
### JavaScript/Node.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const ws = new WebSocket('wss://p2ns.admin/ws', {
|
||||||
|
rejectUnauthorized: false // Accept self-signed certificate
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
console.log('Connected to P2NS WebSocket');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
const message = JSON.parse(data);
|
||||||
|
console.log('Received:', message);
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'log':
|
||||||
|
console.log(`[${message.level}] ${message.message}`);
|
||||||
|
break;
|
||||||
|
case 'update-database':
|
||||||
|
console.log('Database updated, refresh domains');
|
||||||
|
break;
|
||||||
|
case 'update-peers':
|
||||||
|
console.log('Peers updated');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
import websockets
|
||||||
|
import ssl
|
||||||
|
import json
|
||||||
|
|
||||||
|
async def connect_p2ns():
|
||||||
|
ssl_context = ssl.create_default_context()
|
||||||
|
ssl_context.check_hostname = False
|
||||||
|
ssl_context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
uri = "wss://p2ns.admin/ws"
|
||||||
|
|
||||||
|
async with websockets.connect(uri, ssl=ssl_context) as websocket:
|
||||||
|
print("Connected to P2NS WebSocket")
|
||||||
|
|
||||||
|
async for message in websocket:
|
||||||
|
data = json.loads(message)
|
||||||
|
print(f"Received: {data}")
|
||||||
|
|
||||||
|
if data['type'] == 'log':
|
||||||
|
print(f"[{data['level']}] {data['message']}")
|
||||||
|
elif data['type'] == 'update-database':
|
||||||
|
print("Database updated")
|
||||||
|
|
||||||
|
asyncio.run(connect_p2ns())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Handling Rate Limits
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function makeRequest(url, options) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfter = response.headers.get('Retry-After');
|
||||||
|
console.log(`Rate limited. Retry after ${retryAfter} seconds`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
|
||||||
|
return makeRequest(url, options); // Retry
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Request failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handling Connection Errors
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function robustRequest(url, options, maxRetries = 3) {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (i === maxRetries - 1) throw error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Certificate Issues
|
||||||
|
|
||||||
|
If you encounter certificate errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On macOS, trust the CA manually:
|
||||||
|
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./certs/ca.cert.pem
|
||||||
|
|
||||||
|
# On Linux:
|
||||||
|
sudo cp ./certs/ca.cert.pem /usr/local/share/ca-certificates/p2ns-ca.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
|
||||||
|
# On Windows (PowerShell as Administrator):
|
||||||
|
certutil -addstore -f "ROOT" .\certs\ca.cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### DNS Resolution Issues
|
||||||
|
|
||||||
|
Test DNS resolution:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Query P2P domain
|
||||||
|
dig @127.0.0.1 example.tld
|
||||||
|
|
||||||
|
# Query with specific type
|
||||||
|
dig @127.0.0.1 example.tld AAAA
|
||||||
|
|
||||||
|
# Query local DNS record
|
||||||
|
dig @127.0.0.1 local.example A
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Issues
|
||||||
|
|
||||||
|
Check if services are running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check health
|
||||||
|
curl -k https://p2ns.admin/api/health
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
curl -k https://p2ns.admin/api/status
|
||||||
|
|
||||||
|
# View logs via WebSocket or admin interface
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup Operations
|
||||||
|
|
||||||
|
### List All Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/backups
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Manual Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/backups/create
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore from Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/backups/restore \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"backupName": "backup-20240101-000000"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Backup Metadata
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/backups/backup-20240101-000000/metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X DELETE https://p2ns.admin/api/backups/backup-20240101-000000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diagnostics Examples
|
||||||
|
|
||||||
|
### DNS Lookup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# A record
|
||||||
|
curl -k -X POST https://p2ns.admin/api/diagnostics/dns-lookup \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.com",
|
||||||
|
"type": "A"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# MX record
|
||||||
|
curl -k -X POST https://p2ns.admin/api/diagnostics/dns-lookup \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.com",
|
||||||
|
"type": "MX"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ping (Non-streaming)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/diagnostics/ping \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"target": "8.8.8.8",
|
||||||
|
"count": 4
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ping (Streaming)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/diagnostics/ping \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"target": "8.8.8.8",
|
||||||
|
"count": 4,
|
||||||
|
"stream": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Traceroute
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/diagnostics/traceroute \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"target": "8.8.8.8",
|
||||||
|
"stream": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -X POST https://p2ns.admin/api/diagnostics/connection-test \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.com",
|
||||||
|
"port": 443
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bandwidth Information
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/diagnostics/bandwidth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stats and Metrics Examples
|
||||||
|
|
||||||
|
### Get Current Stats
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Historical Metrics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Last hour
|
||||||
|
curl -k "https://p2ns.admin/api/stats/historical?minutes=60"
|
||||||
|
|
||||||
|
# Last 24 hours
|
||||||
|
curl -k "https://p2ns.admin/api/stats/historical?minutes=1440"
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Stats Monitoring
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getStats() {
|
||||||
|
const response = await fetch('https://p2ns.admin/api/stats');
|
||||||
|
const stats = await response.json();
|
||||||
|
|
||||||
|
console.log('Total requests:', stats.requests?.total);
|
||||||
|
console.log('Success rate:', stats.requests?.successRate);
|
||||||
|
console.log('Holesail connections:', stats.holesailChildren?.length);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stats every 10 seconds
|
||||||
|
setInterval(getStats, 10000);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consensus Examples
|
||||||
|
|
||||||
|
### Get Consensus State for a Domain
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/consensus/example.tld
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Consensus Metrics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k https://p2ns.admin/api/consensus/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force Consensus Recalculation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Recalculate all domains
|
||||||
|
curl -k -X POST https://p2ns.admin/api/consensus/recalculate
|
||||||
|
|
||||||
|
# Recalculate specific domain
|
||||||
|
curl -k -X POST https://p2ns.admin/api/consensus/recalculate/example.tld
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript: Monitor Consensus State
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getConsensusState(domain) {
|
||||||
|
const response = await fetch(`https://p2ns.admin/api/consensus/${encodeURIComponent(domain)}`);
|
||||||
|
const state = await response.json();
|
||||||
|
|
||||||
|
console.log(`Domain: ${domain}`);
|
||||||
|
console.log(`Status: ${state.status}`);
|
||||||
|
console.log(`Quorum Met: ${state.quorumMet}`);
|
||||||
|
console.log(`Vote Counts:`, state.voteCounts);
|
||||||
|
|
||||||
|
if (state.status === 'resolved') {
|
||||||
|
console.log(`Resolved Hash: ${state.hash}`);
|
||||||
|
console.log(`Winner: ${state.resolvedClaimant}`);
|
||||||
|
} else if (state.status === 'insufficient_quorum') {
|
||||||
|
console.log(`Need ${state.minVotes} votes, have ${state.totalVotes}`);
|
||||||
|
} else if (state.status === 'tie') {
|
||||||
|
console.log('Tie detected, tie-breaker applied');
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check consensus for a domain
|
||||||
|
getConsensusState('example.tld');
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript: Monitor Consensus Metrics
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function getConsensusMetrics() {
|
||||||
|
const response = await fetch('https://p2ns.admin/api/consensus/metrics');
|
||||||
|
const metrics = await response.json();
|
||||||
|
|
||||||
|
console.log('Total Resolutions:', metrics.resolutions);
|
||||||
|
console.log('Quorum Failures:', metrics.quorumFailures);
|
||||||
|
console.log('Ties:', metrics.ties);
|
||||||
|
console.log('Total Votes:', metrics.totalVotes);
|
||||||
|
console.log('Avg Votes per Domain:', metrics.avgVotesPerDomain);
|
||||||
|
|
||||||
|
// Per-domain statistics
|
||||||
|
metrics.domainResolutions.forEach(({ domain, resolved, failed }) => {
|
||||||
|
console.log(`${domain}: ${resolved} resolved, ${failed} failed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metrics every minute
|
||||||
|
setInterval(getConsensusMetrics, 60000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript: Monitor Domain Resolution Status
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function monitorDomainResolution(domain, interval = 5000) {
|
||||||
|
const checkStatus = async () => {
|
||||||
|
const state = await getConsensusState(domain);
|
||||||
|
|
||||||
|
if (state.status === 'resolved') {
|
||||||
|
console.log(`✓ ${domain} is resolved: ${state.hash}`);
|
||||||
|
return true; // Resolved, stop monitoring
|
||||||
|
} else {
|
||||||
|
console.log(`⏳ ${domain} status: ${state.status}`);
|
||||||
|
if (state.status === 'insufficient_quorum') {
|
||||||
|
console.log(` Need ${state.minVotes} votes, have ${state.totalVotes}`);
|
||||||
|
}
|
||||||
|
return false; // Not resolved, continue monitoring
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately
|
||||||
|
const resolved = await checkStatus();
|
||||||
|
if (resolved) return;
|
||||||
|
|
||||||
|
// Continue checking at interval
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
const resolved = await checkStatus();
|
||||||
|
if (resolved) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
|
||||||
|
return timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor a domain until it's resolved
|
||||||
|
monitorDomainResolution('example.tld');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python: Consensus State Monitoring
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
def get_consensus_state(domain):
|
||||||
|
url = f"https://p2ns.admin/api/consensus/{domain}"
|
||||||
|
response = requests.get(url, verify=False)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def get_consensus_metrics():
|
||||||
|
url = "https://p2ns.admin/api/consensus/metrics"
|
||||||
|
response = requests.get(url, verify=False)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# Get consensus state
|
||||||
|
state = get_consensus_state("example.tld")
|
||||||
|
print(f"Status: {state['status']}")
|
||||||
|
print(f"Quorum Met: {state['quorumMet']}")
|
||||||
|
print(f"Vote Counts: {state['voteCounts']}")
|
||||||
|
|
||||||
|
# Get metrics
|
||||||
|
metrics = get_consensus_metrics()
|
||||||
|
print(f"Total Resolutions: {metrics['resolutions']}")
|
||||||
|
print(f"Quorum Failures: {metrics['quorumFailures']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force Recalculation After Network Changes
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function forceRecalculation(domain = null) {
|
||||||
|
const endpoint = domain
|
||||||
|
? `/api/consensus/recalculate/${encodeURIComponent(domain)}`
|
||||||
|
: '/api/consensus/recalculate';
|
||||||
|
|
||||||
|
const response = await fetch(`https://p2ns.admin${endpoint}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log(result.message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate all domains (useful after network topology changes)
|
||||||
|
await forceRecalculation();
|
||||||
|
|
||||||
|
// Recalculate specific domain (useful after adding/removing claims)
|
||||||
|
await forceRecalculation('example.tld');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Examples
|
||||||
|
|
||||||
|
### Batch Domain Addition
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const domains = [
|
||||||
|
{ domain: 'example1.tld', hash: 'hs://...', ssl: false },
|
||||||
|
{ domain: 'example2.tld', hash: 'hs://...', ssl: true },
|
||||||
|
{ domain: 'example3.tld', hash: 'hs://...', ssl: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { domain, hash, ssl } of domains) {
|
||||||
|
await fetch('https://p2ns.admin/api/add-domain', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain, hash, ssl: ssl || false })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring System Health
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function monitorHealth() {
|
||||||
|
const response = await fetch('https://p2ns.admin/api/health?probe=readiness');
|
||||||
|
const health = await response.json();
|
||||||
|
|
||||||
|
if (health.status !== 'healthy') {
|
||||||
|
console.warn('System is degraded:', health);
|
||||||
|
// Send alert, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor every 30 seconds
|
||||||
|
setInterval(monitorHealth, 30000);
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# P2NS Glossary
|
||||||
|
|
||||||
|
This glossary defines key terms and concepts used throughout P2NS documentation.
|
||||||
|
|
||||||
|
## P2NS Core Concepts
|
||||||
|
|
||||||
|
### P2NS (Peer-to-Peer Name System)
|
||||||
|
A decentralized DNS alternative that resolves domain names to IP addresses using peer-to-peer consensus rather than centralized DNS servers. Enables users to claim domains, share them across a P2P network, and resolve them locally.
|
||||||
|
|
||||||
|
### Domain Claim
|
||||||
|
A record in the distributed ledger asserting ownership of a domain. Claims are stored as `claim:{domain}:{claimant}` entries containing the Holesail hash, timestamp, service clients, and SSL flag. Multiple peers can claim the same domain; consensus determines the winner.
|
||||||
|
|
||||||
|
### Vote
|
||||||
|
A record supporting a specific domain claimant. Votes are stored as `vote:{domain}:{claimant}:{voter}` entries. Peers vote to support claims they trust, and consensus uses vote counts to resolve ownership disputes.
|
||||||
|
|
||||||
|
### Auto-Vote
|
||||||
|
Automatic voting performed by P2NS when certain conditions are met. The system automatically votes for claims when a peer joins the network or when new claims are discovered, based on configured rules.
|
||||||
|
|
||||||
|
### Consensus
|
||||||
|
The process of determining which claimant owns a domain when multiple claims exist. Uses quorum-based voting to reach agreement across the P2P network. See [CONSENSUS.md](CONSENSUS.md) for details.
|
||||||
|
|
||||||
|
### Quorum
|
||||||
|
The minimum number of votes required for consensus to be valid. Calculated as `max(MIN_VOTES, ceil(activePeers * THRESHOLD))`. Prevents decisions from being made with insufficient participation.
|
||||||
|
|
||||||
|
### Internal Domain
|
||||||
|
A domain served locally by a P2NS plugin. Internal domains are automatically discovered from `plugin-sites/{domain}/config.json` files. Examples: `p2ns.admin`, `peer.directory`, `global.profile`.
|
||||||
|
|
||||||
|
### P2P Domain
|
||||||
|
A domain resolved through the P2P network via consensus. P2P domains are claimed by peers and resolved to Holesail connections that tunnel traffic to the domain owner's server.
|
||||||
|
|
||||||
|
### Version Preference
|
||||||
|
When a domain exists both as a P2P claim and in public DNS, users can choose which version to resolve. Preferences are stored in `selector_cache.json` and can be set to `p2p` or `public`.
|
||||||
|
|
||||||
|
### DNS Conflict
|
||||||
|
A domain that has both a P2P claim and a public DNS record. P2NS detects these conflicts and allows users to choose which version to use via the admin interface.
|
||||||
|
|
||||||
|
### Hybrid DNS
|
||||||
|
P2NS's approach to DNS resolution that combines P2P domain resolution with fallback to public DNS servers. Local DNS records take priority, followed by P2P consensus, then public DNS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Holepunch Ecosystem
|
||||||
|
|
||||||
|
P2NS is built on the [Holepunch](https://holepunch.to/) ecosystem of P2P technologies.
|
||||||
|
|
||||||
|
### Hyperswarm
|
||||||
|
The P2P networking layer that enables peer discovery and connection. Uses DHT (Distributed Hash Table) for peer discovery and handles NAT traversal. P2NS uses Hyperswarm to connect peers and replicate data.
|
||||||
|
|
||||||
|
### Hypercore
|
||||||
|
An append-only log data structure that forms the foundation of Holepunch storage. Each entry is cryptographically signed and linked, creating a tamper-proof history. Used internally by Hyperbee, HyperDB, and Hyperdrive.
|
||||||
|
|
||||||
|
### Hyperbee
|
||||||
|
A key-value store built on Hypercore. Provides B-tree indexing for efficient lookups. P2NS uses Hyperbee (via Autopass) to store domain claims and votes.
|
||||||
|
|
||||||
|
### HyperDB
|
||||||
|
A document database for P2NS plugins. Supports schemas, collections, indexes, and queries. Enables plugins to store and replicate structured data across peers. See [plugins/HYPERDB.md](plugins/HYPERDB.md).
|
||||||
|
|
||||||
|
### Hyperdrive
|
||||||
|
A distributed file system built on Hypercore. Enables plugins to store and share files across the P2P network. Supports file operations, directory structures, and replication. See [plugins/HYPERDRIVE.md](plugins/HYPERDRIVE.md).
|
||||||
|
|
||||||
|
### Autopass
|
||||||
|
A distributed ledger built on Hyperbee used for storing domain claims and votes. Provides the foundation for P2NS consensus by replicating claim/vote data across all connected peers.
|
||||||
|
|
||||||
|
### Corestore
|
||||||
|
Storage management for multiple Hypercores. Handles persistence, caching, and lifecycle of cores. P2NS uses Corestore to manage storage for Autopass, HyperDB, and Hyperdrive instances.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Holesail
|
||||||
|
|
||||||
|
[Holesail](https://holesail.io/) provides UDP hole-punching for P2P tunneling.
|
||||||
|
|
||||||
|
### Holesail
|
||||||
|
A library for creating encrypted P2P tunnels through firewalls and NATs using UDP hole-punching. Enables direct connections between peers without requiring port forwarding or public IP addresses.
|
||||||
|
|
||||||
|
### Holesail Server
|
||||||
|
A Holesail instance that exposes a local service (HTTP, TCP, UDP) to the P2P network. Generates a connection hash that clients use to connect. Created via the admin interface or SDK.
|
||||||
|
|
||||||
|
### Holesail Client
|
||||||
|
A Holesail instance that connects to a remote server using its connection hash. Creates a local port that tunnels traffic to the remote service. Used to access P2P domains.
|
||||||
|
|
||||||
|
### Connection Hash
|
||||||
|
A unique identifier for a Holesail server, formatted as `hs://{hash}`. The hash is derived from cryptographic keys and enables peers to discover and connect to the server. Example: `hs://s00084bf87dfa89a3048fb081c0e6207eb5a`.
|
||||||
|
|
||||||
|
### Service
|
||||||
|
A named endpoint within a domain claim. Services allow a single domain to expose multiple Holesail connections on different ports. Each service has a `serviceName`, `key` (hash), `port`, and `protocol`.
|
||||||
|
|
||||||
|
### Service Subscription
|
||||||
|
Automatic connection to services published by domain owners. When subscribed, P2NS creates Holesail clients for the domain's services. "Subscribe All" automatically subscribes to all current and future services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Networking
|
||||||
|
|
||||||
|
### Virtual Interface
|
||||||
|
An IP alias created on the loopback interface (lo0/lo) to give each domain a unique local IP address. Enables the system to route traffic to the correct domain based on destination IP. Managed automatically by P2NS.
|
||||||
|
|
||||||
|
### Subnet
|
||||||
|
A range of IP addresses used for allocating virtual interface IPs. Configured via `SUBNETS` environment variable. Default: `192.168.3.0/24`. Multiple subnets can be configured for large deployments.
|
||||||
|
|
||||||
|
### Loopback Interface
|
||||||
|
The network interface used for local communication (`127.0.0.1`). P2NS creates IP aliases on the loopback interface to assign unique IPs to each domain without affecting external networking.
|
||||||
|
|
||||||
|
### SNI (Server Name Indication)
|
||||||
|
A TLS extension that allows the client to specify which hostname it's connecting to. P2NS uses SNI to route HTTPS requests to the correct domain handler based on the requested hostname.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin System
|
||||||
|
|
||||||
|
### Plugin
|
||||||
|
A self-contained module that extends P2NS functionality. Plugins are stored in `plugin-sites/{domain}/` and can serve static files, handle dynamic requests, and access the full SDK. See [plugins/README.md](plugins/README.md).
|
||||||
|
|
||||||
|
### Plugin SDK
|
||||||
|
The API provided to plugins for accessing P2NS functionality. Includes modules for state access, DNS operations, domain management, Holesail control, peer management, and more. See [plugins/PLUGIN_SDK.md](plugins/PLUGIN_SDK.md).
|
||||||
|
|
||||||
|
### Protomux Channels
|
||||||
|
P2P communication channels for plugins using the Protomux protocol. Enables plugins to send messages directly between peers without going through HTTP. See [plugins/PLUGIN_CHANNELS.md](plugins/PLUGIN_CHANNELS.md).
|
||||||
|
|
||||||
|
### Admin Panel Actions
|
||||||
|
Custom operations that plugins can register to appear in the admin interface. Actions are async functions that can be triggered from the Plugins tab. Registered via `sdk.admin.registerAction()`.
|
||||||
|
|
||||||
|
### Admin Panel Settings
|
||||||
|
Configuration options that plugins can register for user customization. Settings appear as forms in the admin interface and persist to `cache/plugin-settings/{domain}.json`. Registered via `sdk.admin.registerSetting()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Certificate Authority (CA)
|
||||||
|
P2NS includes a local CA for generating TLS certificates. The root CA can be installed in the system trust store to enable HTTPS for all P2NS domains without browser warnings.
|
||||||
|
|
||||||
|
### Ed25519
|
||||||
|
The elliptic curve signature algorithm used for authentication tokens. Plugins use Ed25519-signed tokens to verify that requests come from the local peer.
|
||||||
|
|
||||||
|
### Authentication Token
|
||||||
|
A signed token used to authenticate write operations in plugins. Contains the peer ID, timestamp, expiration, and cryptographic signature. Generated via `/api/token` endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
### domains.json
|
||||||
|
Configuration file storing the list of domains claimed by the local peer. Each entry contains the domain name, Holesail hash, and optional SSL flag.
|
||||||
|
|
||||||
|
### local_dns.json
|
||||||
|
Custom DNS records that take priority over P2P and public DNS. Supports all standard record types (A, AAAA, MX, TXT, SRV, etc.).
|
||||||
|
|
||||||
|
### selector_cache.json
|
||||||
|
Stores version preferences for domains with both P2P and public DNS records. Maps domain names to `p2p` or `public`.
|
||||||
|
|
||||||
|
### holesail_servers.json / holesail_clients.json
|
||||||
|
Persisted configuration for Holesail servers and clients. Automatically restored on P2NS startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [README.md](../README.md) - Main P2NS documentation
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) - Internal module documentation
|
||||||
|
- [CONSENSUS.md](CONSENSUS.md) - Consensus mechanism deep dive
|
||||||
|
- [plugins/README.md](plugins/README.md) - Plugin system guide
|
||||||
|
- [plugins/PLUGIN_SDK.md](plugins/PLUGIN_SDK.md) - Plugin SDK reference
|
||||||
|
- [RESTAPI.md](RESTAPI.md) - Admin API documentation
|
||||||
|
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
# P2NS Network Topology
|
||||||
|
|
||||||
|
This document illustrates what a large-scale P2NS network looks like with many peers, domains, and services.
|
||||||
|
|
||||||
|
## Network Overview
|
||||||
|
|
||||||
|
A mature P2NS network consists of interconnected peers, each potentially claiming domains, voting on claims, and subscribing to services from other peers.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph Internet[Internet / Hyperswarm DHT]
|
||||||
|
DHT[Distributed Hash Table]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Region1[Region: North America]
|
||||||
|
P1[Peer: alice]
|
||||||
|
P2[Peer: bob]
|
||||||
|
P3[Peer: charlie]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Region2[Region: Europe]
|
||||||
|
P4[Peer: diana]
|
||||||
|
P5[Peer: erik]
|
||||||
|
P6[Peer: fiona]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Region3[Region: Asia]
|
||||||
|
P7[Peer: george]
|
||||||
|
P8[Peer: hana]
|
||||||
|
end
|
||||||
|
|
||||||
|
P1 <--> DHT
|
||||||
|
P2 <--> DHT
|
||||||
|
P3 <--> DHT
|
||||||
|
P4 <--> DHT
|
||||||
|
P5 <--> DHT
|
||||||
|
P6 <--> DHT
|
||||||
|
P7 <--> DHT
|
||||||
|
P8 <--> DHT
|
||||||
|
|
||||||
|
P1 <-.-> P2
|
||||||
|
P2 <-.-> P3
|
||||||
|
P4 <-.-> P5
|
||||||
|
P5 <-.-> P6
|
||||||
|
P7 <-.-> P8
|
||||||
|
P1 <-.-> P4
|
||||||
|
P3 <-.-> P7
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Large Network
|
||||||
|
|
||||||
|
### Network Statistics
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Total Peers | 50 |
|
||||||
|
| Active Peers | 42 |
|
||||||
|
| Total Domains Claimed | 150 |
|
||||||
|
| Unique Domains | 120 |
|
||||||
|
| Contested Domains | 30 |
|
||||||
|
| Total Services | 200+ |
|
||||||
|
| Average Votes per Domain | 8 |
|
||||||
|
|
||||||
|
### Domain Distribution
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
pie title Domain Ownership Distribution
|
||||||
|
"Single Owner" : 90
|
||||||
|
"2 Claimants" : 20
|
||||||
|
"3+ Claimants" : 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Peer Roles
|
||||||
|
|
||||||
|
In a large network, peers naturally take on different roles:
|
||||||
|
|
||||||
|
### Domain Owners
|
||||||
|
Peers that claim and host domains:
|
||||||
|
|
||||||
|
```
|
||||||
|
Peer: alice (pk: a1b2c3...)
|
||||||
|
├── Claims:
|
||||||
|
│ ├── my-blog.p2p (hs://abc123...)
|
||||||
|
│ ├── photo-gallery.p2p (hs://def456...)
|
||||||
|
│ └── api.my-blog.p2p (hs://ghi789...)
|
||||||
|
├── Services Published:
|
||||||
|
│ ├── my-blog.p2p:web (port 443)
|
||||||
|
│ ├── my-blog.p2p:api (port 8080)
|
||||||
|
│ └── photo-gallery.p2p:web (port 443)
|
||||||
|
└── Votes Cast: 45 domains
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Consumers
|
||||||
|
Peers that primarily subscribe to others' services:
|
||||||
|
|
||||||
|
```
|
||||||
|
Peer: bob (pk: d4e5f6...)
|
||||||
|
├── Claims: (none)
|
||||||
|
├── Subscriptions:
|
||||||
|
│ ├── alice/my-blog.p2p:web -> localhost:8001
|
||||||
|
│ ├── diana/shop.p2p:web -> localhost:8002
|
||||||
|
│ └── erik/chat.p2p:* (subscribe-all)
|
||||||
|
└── Votes Cast: 30 domains
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infrastructure Nodes
|
||||||
|
High-availability peers that help maintain network health:
|
||||||
|
|
||||||
|
```
|
||||||
|
Peer: infra-node-1 (pk: x7y8z9...)
|
||||||
|
├── Claims:
|
||||||
|
│ └── status.network.p2p
|
||||||
|
├── Uptime: 99.9%
|
||||||
|
├── Connected Peers: 48/50
|
||||||
|
├── Votes Cast: 120 domains (all known)
|
||||||
|
└── Role: Helps reach quorum for contested domains
|
||||||
|
```
|
||||||
|
|
||||||
|
## Domain Lifecycle
|
||||||
|
|
||||||
|
### New Domain Claim
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Alice as alice
|
||||||
|
participant Network as P2NS Network
|
||||||
|
participant Bob as bob
|
||||||
|
participant Charlie as charlie
|
||||||
|
|
||||||
|
Alice->>Network: Claim "shop.p2p" (hs://abc...)
|
||||||
|
Network->>Bob: Replicate claim
|
||||||
|
Network->>Charlie: Replicate claim
|
||||||
|
|
||||||
|
Note over Network: Auto-vote triggered
|
||||||
|
|
||||||
|
Bob->>Network: Vote for alice/shop.p2p
|
||||||
|
Charlie->>Network: Vote for alice/shop.p2p
|
||||||
|
|
||||||
|
Note over Network: Quorum reached (3 votes)
|
||||||
|
|
||||||
|
Network->>Alice: Consensus: resolved
|
||||||
|
Network->>Bob: Consensus: resolved
|
||||||
|
Network->>Charlie: Consensus: resolved
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contested Domain
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Alice as alice
|
||||||
|
participant Network as P2NS Network
|
||||||
|
participant Bob as bob
|
||||||
|
participant Diana as diana
|
||||||
|
|
||||||
|
Note over Network: "popular.p2p" claimed by alice
|
||||||
|
|
||||||
|
Diana->>Network: Claim "popular.p2p" (hs://xyz...)
|
||||||
|
|
||||||
|
Note over Network: Now 2 claimants
|
||||||
|
|
||||||
|
Network->>Bob: Which claim to support?
|
||||||
|
Bob->>Network: Vote for alice (older claim)
|
||||||
|
|
||||||
|
Note over Network: Vote count: alice=25, diana=5
|
||||||
|
Note over Network: Quorum: 15 (30 peers * 0.5)
|
||||||
|
|
||||||
|
Network->>Diana: Consensus: alice wins
|
||||||
|
Diana->>Diana: Remove claim or keep trying
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Mesh
|
||||||
|
|
||||||
|
Large networks often develop service meshes where domains expose multiple services:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ shop.example.p2p │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Services: │
|
||||||
|
│ ├── web (hs://aaa...) port 443 - Main website │
|
||||||
|
│ ├── api (hs://bbb...) port 8080 - REST API │
|
||||||
|
│ ├── graphql (hs://ccc...) port 4000 - GraphQL endpoint │
|
||||||
|
│ ├── ws (hs://ddd...) port 3000 - WebSocket server │
|
||||||
|
│ └── metrics (hs://eee...) port 9090 - Prometheus metrics │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ chat.example.p2p │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Services: │
|
||||||
|
│ ├── web (hs://fff...) port 443 - Web client │
|
||||||
|
│ ├── api (hs://ggg...) port 8080 - REST API │
|
||||||
|
│ └── rtc (hs://hhh...) port 5000 - WebRTC signaling │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subscriber View
|
||||||
|
|
||||||
|
A peer subscribing to multiple services:
|
||||||
|
|
||||||
|
```
|
||||||
|
Local Port Mappings (bob's machine):
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Local Port │ Remote Service │ Status │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ 8001 │ shop.example.p2p:web │ Connected │
|
||||||
|
│ 8002 │ shop.example.p2p:api │ Connected │
|
||||||
|
│ 8003 │ chat.example.p2p:web │ Connected │
|
||||||
|
│ 8004 │ chat.example.p2p:api │ Connecting... │
|
||||||
|
│ 8005 │ blog.alice.p2p:web │ Connected │
|
||||||
|
│ 8006 │ status.network.p2p:metrics │ Connected │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consensus at Scale
|
||||||
|
|
||||||
|
### Quorum Dynamics
|
||||||
|
|
||||||
|
With 50 peers and default settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
CONSENSUS_QUORUM_THRESHOLD = 0.5
|
||||||
|
CONSENSUS_MIN_VOTES = 2
|
||||||
|
|
||||||
|
Quorum requirement = max(2, ceil(50 * 0.5)) = 25 votes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vote Distribution Example
|
||||||
|
|
||||||
|
```
|
||||||
|
Domain: popular-service.p2p
|
||||||
|
|
||||||
|
Claimants:
|
||||||
|
├── alice (pk: a1b2...) - 28 votes ✓ WINNER
|
||||||
|
├── bob (pk: d4e5...) - 15 votes
|
||||||
|
└── charlie (pk: g7h8...) - 7 votes
|
||||||
|
|
||||||
|
Total votes: 50
|
||||||
|
Quorum: 25 ✓ Met
|
||||||
|
Status: resolved -> alice
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Partition Scenario
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph Partition1[Partition A - 30 peers]
|
||||||
|
PA1[alice]
|
||||||
|
PA2[bob]
|
||||||
|
PA3[...]
|
||||||
|
PA4[30 peers total]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Partition2[Partition B - 20 peers]
|
||||||
|
PB1[charlie]
|
||||||
|
PB2[diana]
|
||||||
|
PB3[...]
|
||||||
|
PB4[20 peers total]
|
||||||
|
end
|
||||||
|
|
||||||
|
PA1 -.X.- PB1
|
||||||
|
|
||||||
|
Note1[Partition A: Quorum = 15, can resolve]
|
||||||
|
Note2[Partition B: Quorum = 10, can resolve]
|
||||||
|
```
|
||||||
|
|
||||||
|
During partition:
|
||||||
|
- Each partition calculates quorum based on visible peers
|
||||||
|
- Resolutions may differ between partitions
|
||||||
|
- On reconnection, claims/votes merge and re-resolve
|
||||||
|
|
||||||
|
## Plugin Distribution
|
||||||
|
|
||||||
|
Large networks often have popular plugins replicated across peers:
|
||||||
|
|
||||||
|
```
|
||||||
|
Plugin: global.profile
|
||||||
|
├── Installed on: 45/50 peers (90%)
|
||||||
|
├── HyperDB replication: Active
|
||||||
|
└── Profiles synced: 2,500+
|
||||||
|
|
||||||
|
Plugin: peer.directory
|
||||||
|
├── Installed on: 50/50 peers (100%)
|
||||||
|
├── Domains indexed: 150
|
||||||
|
└── Search queries/day: 500+
|
||||||
|
|
||||||
|
Plugin: domain.consensus
|
||||||
|
├── Installed on: 35/50 peers (70%)
|
||||||
|
├── Visualizations: Real-time
|
||||||
|
└── Vote tracking: All domains
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scaling Considerations
|
||||||
|
|
||||||
|
### Subnet Allocation
|
||||||
|
|
||||||
|
Large deployments need multiple subnets:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SUBNETS=[
|
||||||
|
{"base":"192.168.1.0","cidr":24,"startIndex":2,"name":"Primary"},
|
||||||
|
{"base":"192.168.2.0","cidr":24,"startIndex":2,"name":"Secondary"},
|
||||||
|
{"base":"192.168.3.0","cidr":24,"startIndex":2,"name":"Tertiary"},
|
||||||
|
{"base":"10.0.0.0","cidr":16,"startIndex":2,"name":"Extended"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Capacity:
|
||||||
|
- 3 x /24 subnets = 759 domains
|
||||||
|
- 1 x /16 subnet = 65,534 domains
|
||||||
|
- Total: 66,293 possible domains per peer
|
||||||
|
|
||||||
|
### Performance Tuning
|
||||||
|
|
||||||
|
For large networks:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Increase DNS pool for more concurrent queries
|
||||||
|
DNS_POOL_SIZE=10
|
||||||
|
|
||||||
|
# Adjust consensus for larger peer counts
|
||||||
|
CONSENSUS_QUORUM_THRESHOLD=0.3
|
||||||
|
CONSENSUS_MIN_VOTES=5
|
||||||
|
|
||||||
|
# Increase cache TTLs to reduce load
|
||||||
|
# (configured in code, not env vars)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Usage
|
||||||
|
|
||||||
|
Estimated resources for a peer in a 50-peer network:
|
||||||
|
|
||||||
|
| Resource | Typical Usage |
|
||||||
|
|----------|---------------|
|
||||||
|
| Memory | 200-500 MB |
|
||||||
|
| CPU | 1-5% idle, 20% during sync |
|
||||||
|
| Disk | 100 MB - 1 GB (depending on plugins) |
|
||||||
|
| Bandwidth | 10-50 KB/s average |
|
||||||
|
| Open connections | 50-100 |
|
||||||
|
|
||||||
|
## Network Health Monitoring
|
||||||
|
|
||||||
|
### Key Metrics to Watch
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard: P2NS Network Health
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ Connected Peers: 48/50 Uptime: 99.2% │
|
||||||
|
├────────────────────────────────────────────────────────┤
|
||||||
|
│ Consensus Health: │
|
||||||
|
│ ├── Resolved domains: 115/120 (95.8%) │
|
||||||
|
│ ├── Quorum failures: 3 │
|
||||||
|
│ ├── Active ties: 2 │
|
||||||
|
│ └── Validation failures: 0 │
|
||||||
|
├────────────────────────────────────────────────────────┤
|
||||||
|
│ Holesail Connections: │
|
||||||
|
│ ├── Servers running: 5 │
|
||||||
|
│ ├── Clients connected: 12 │
|
||||||
|
│ └── Failed connections: 1 │
|
||||||
|
├────────────────────────────────────────────────────────┤
|
||||||
|
│ DNS Queries (last hour): │
|
||||||
|
│ ├── Total: 1,250 │
|
||||||
|
│ ├── P2P resolved: 800 (64%) │
|
||||||
|
│ ├── Public fallback: 400 (32%) │
|
||||||
|
│ └── Local DNS: 50 (4%) │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alerting Thresholds
|
||||||
|
|
||||||
|
| Condition | Warning | Critical |
|
||||||
|
|-----------|---------|----------|
|
||||||
|
| Connected peers | < 80% | < 50% |
|
||||||
|
| Quorum failures | > 5% | > 20% |
|
||||||
|
| Holesail failures | > 10% | > 30% |
|
||||||
|
| DNS resolution time | > 500ms | > 2000ms |
|
||||||
|
|
||||||
|
## Growth Patterns
|
||||||
|
|
||||||
|
### Organic Growth
|
||||||
|
|
||||||
|
```
|
||||||
|
Month 1: 5 peers, 10 domains, 20 services
|
||||||
|
Month 3: 15 peers, 40 domains, 80 services
|
||||||
|
Month 6: 30 peers, 100 domains, 200 services
|
||||||
|
Year 1: 50 peers, 200 domains, 500 services
|
||||||
|
Year 2: 100 peers, 500 domains, 1500 services
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trust Networks
|
||||||
|
|
||||||
|
As networks grow, trust patterns emerge:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph TrustedCore[Trusted Core - High Vote Weight]
|
||||||
|
T1[infra-1]
|
||||||
|
T2[infra-2]
|
||||||
|
T3[alice]
|
||||||
|
T4[bob]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ActiveUsers[Active Users]
|
||||||
|
A1[charlie]
|
||||||
|
A2[diana]
|
||||||
|
A3[erik]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph NewPeers[New Peers]
|
||||||
|
N1[new-1]
|
||||||
|
N2[new-2]
|
||||||
|
end
|
||||||
|
|
||||||
|
T1 --> A1
|
||||||
|
T2 --> A2
|
||||||
|
T3 --> A3
|
||||||
|
A1 --> N1
|
||||||
|
A2 --> N2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [CONSENSUS.md](CONSENSUS.md) - Consensus mechanism details
|
||||||
|
- [GLOSSARY.md](GLOSSARY.md) - Terms and concepts
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) - System internals
|
||||||
|
- [README.md](../README.md) - Getting started
|
||||||
|
|
||||||
@@ -0,0 +1,972 @@
|
|||||||
|
# P2NS: Peer-to-Peer Decentralized DNS System
|
||||||
|
|
||||||
|
P2NS (Peer-to-Peer Name System) is a robust, firewall-resistant peer-to-peer (P2P) DNS resolution system designed to operate independently of centralized DNS infrastructure. Utilizing UDP hole-punching via the Holesail library, P2NS enables seamless connectivity across challenging network environments, including those behind firewalls, NAT, CGNAT, and restricted connections like 4G/5G or satellite internet (e.g., Starlink).
|
||||||
|
|
||||||
|
Built in Node.js, P2NS integrates decentralized data storage (Corestore), peer discovery (Hyperswarm), secure invitations (Autopass), and dynamic tunneling (Holesail). It supports hybrid DNS resolution, automatic virtual network interface creation, HTTP/HTTPS proxying with WebSocket support, TLS certificate management, a peer directory, and a comprehensive web-based admin interface. Ideal for decentralized applications and privacy-focused networking, P2NS is currently optimized for macOS and Linux, with Windows support planned.
|
||||||
|
|
||||||
|
[](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/domains-tab.png)
|
||||||
|
|
||||||
|
|
||||||
|
Example Peer-to-Peer domain: https://cert.decode (globally avalible to all peers)
|
||||||
|
|
||||||
|
[](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/p2p-domain-cert-dot-decode.png)
|
||||||
|
|
||||||
|
Local Example Plugin site with Peer-to-Peer access via the P2NS SDK.
|
||||||
|
|
||||||
|
[](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/internal-domain-example-dot-plugin.png)
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Features](#features)
|
||||||
|
- [Architecture Overview](#architecture-overview)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Running the System](#running-the-system)
|
||||||
|
- [Adding Domains](#adding-domains)
|
||||||
|
- [Consensus and Voting](#consensus-and-voting)
|
||||||
|
- [Using the Peer Directory](#using-the-peer-directory)
|
||||||
|
- [Using the Admin Interface](#using-the-admin-interface)
|
||||||
|
- [DNS Conflict Selector](#dns-conflict-selector)
|
||||||
|
- [Hybrid DNS Resolution](#hybrid-dns-resolution)
|
||||||
|
- [Proxying and Tunneling](#proxying-and-tunneling)
|
||||||
|
- [Service Subscriptions](#service-subscriptions)
|
||||||
|
- [Certificate Authority and TLS](#certificate-authority-and-tls)
|
||||||
|
- [Logging and Debugging](#logging-and-debugging)
|
||||||
|
- [Configuration Options](#configuration-options)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
|
- [Backup and Recovery](#backup-and-recovery)
|
||||||
|
- [Health Checks and Diagnostics](#health-checks-and-diagnostics)
|
||||||
|
- [System Metrics and Monitoring](#system-metrics-and-monitoring)
|
||||||
|
- [Subnet Configuration](#subnet-configuration)
|
||||||
|
- [Peer Management](#peer-management)
|
||||||
|
- [Resource Validation and Cleanup](#resource-validation-and-cleanup)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
- [Security Considerations](#security-considerations)
|
||||||
|
- [Recent Enhancements](#recent-enhancements)
|
||||||
|
- [Future Enhancements](#future-enhancements)
|
||||||
|
- [Contributing](#contributing)
|
||||||
|
- [Additional Documentation](#additional-documentation)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
P2NS provides a comprehensive set of features for decentralized networking:
|
||||||
|
|
||||||
|
- **Decentralized DNS Resolution**: Resolves domains to hashes via a P2P core, independent of ICANN or central DNS servers.
|
||||||
|
- **Firewall and NAT Traversal**: Uses Holesail for UDP hole-punching to connect across restrictive networks.
|
||||||
|
- **Dynamic Local IP Assignment**: Assigns unique local IPs (e.g., `192.168.3.x`) to domains via virtual network interfaces.
|
||||||
|
- **Hybrid DNS Resolution**: Falls back to public DNS (e.g., `1.1.1.1`) for non-P2P domains, with custom local DNS record support.
|
||||||
|
- **DNS Conflict Selector**: Allows users to choose between P2P and public DNS records for domains with both, managed via the admin interface and persisted in `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`).
|
||||||
|
- **Integrated Proxy Servers**: HTTP (port 80) and HTTPS (port 443) proxies with automatic HTTP-to-HTTPS redirection and WebSocket support. Supports SSL/TLS connections for domains that require secure tunneling.
|
||||||
|
- **Holesail Server and Client Management**: Persistent Holesail servers and clients for tunneling, manageable via the admin interface.
|
||||||
|
- **Invitation-Based Peer Joining**: Master nodes issue invites to joiners, with optional support for joiners to issue invites (`ALLOW_ANY_WRITER_INVITES`). Master nodes proactively send invites to new peers and automatically attempt to reconnect when connections drop.
|
||||||
|
- **Voting and Consensus**: Simple voting system for domain claims to resolve conflicts and ensure consensus.
|
||||||
|
- **Automatic Certificate Management**: Generates and installs a root CA and per-domain certificates with SANs for secure TLS connections.
|
||||||
|
- **Peer Directory Interface**: Web-based directory at `https://peer.directory` for browsing and searching domains.
|
||||||
|
- **Web-Based Admin Interface**: Comprehensive management panel at `https://p2ns.admin` for domains, Holesail servers/clients, local DNS, certificates, interfaces, logs, settings, and plugins.
|
||||||
|
- **Plugin Management System**: Full plugin lifecycle management with start/stop/restart capabilities, action registration, settings management, and real-time log viewing via the admin interface.
|
||||||
|
- **Stateful Persistence**: Stores data in Corestore, domains in `cache/domains.json`, local DNS in `cache/local_dns.json`, Holesail configurations in `cache/holesail_servers.json` and `cache/holesail_clients.json`, DNS version preferences in `cache/selector_cache.json`, service subscriptions in `cache/subscriptions.json`, peer tracking in `cache/peer_history.json` and `cache/peer_metrics.json`, and plugin settings in `cache/plugin-settings/{domain}.json`.
|
||||||
|
- **Backup and Recovery**: Automatic and manual backup system with rotation, restore functionality, and metadata tracking.
|
||||||
|
- **Health Checks and Diagnostics**: Comprehensive health monitoring with liveness/readiness probes, DNS lookup, ping, traceroute, connection testing, and bandwidth monitoring.
|
||||||
|
- **System Metrics and Monitoring**: Real-time and historical metrics collection with configurable sampling, aggregation, and retention periods.
|
||||||
|
- **Subnet Configuration**: Multi-subnet support with CIDR notation, allowing flexible IP allocation across multiple network ranges.
|
||||||
|
- **Resource Validation and Tracking**: Automatic validation of system resources, tracking of peer connections, and resource cleanup.
|
||||||
|
- **Circuit Breaker Pattern**: Automatic failure detection and recovery with exponential backoff retry logic.
|
||||||
|
- **Rate Limiting**: Built-in rate limiting for API endpoints to prevent abuse and ensure system stability.
|
||||||
|
- **Enhanced Error Handling**: Comprehensive error handling with retry logic, graceful degradation, and detailed error reporting.
|
||||||
|
- **Logging**: Leveled logging (DEBUG, INFO, WARN, ERROR) with prefixes for debugging.
|
||||||
|
- **Graceful Shutdown**: Cleans up connections, channels, virtual interfaces, and child processes on exit.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
P2NS is a modular Node.js application with the following components:
|
||||||
|
|
||||||
|
- **Core Components**:
|
||||||
|
- **Corestore & Autopass** (`core.js`): Manages decentralized storage and secure writer additions. Stores claims (`claim:domain:claimant`) and votes (`vote:domain:claimant:voter`).
|
||||||
|
- **Hyperswarm** (`swarm.js`): Handles peer discovery using a fixed topic (`sha256('p2ns-dns')`).
|
||||||
|
- **Protomux** (`p2ns.js`): Multiplexes channels for invites and requests.
|
||||||
|
|
||||||
|
- **DNS Handling** (`dns.js`):
|
||||||
|
- UDP server on port 53 for P2P and public DNS resolution.
|
||||||
|
- Supports local DNS records, conflict resolution, and fallback to public DNS.
|
||||||
|
|
||||||
|
- **Proxying** (`internal_domains_proxy.js`, `p2p_domains_proxy.js`):
|
||||||
|
- HTTPS server (port 443) and HTTP redirect server (port 80) for internal and P2P domains.
|
||||||
|
- Handles WebSocket upgrades for real-time applications.
|
||||||
|
- Supports SSL/TLS connections for domains that require secure tunneling (uses HTTPS/WSS when SSL flag is enabled).
|
||||||
|
- Manages version preferences (P2P vs public internet) and routing logic.
|
||||||
|
|
||||||
|
- **Holesail Integration** (`holesail.js`, `admin.js`):
|
||||||
|
- Manages Holesail clients and servers for P2P tunneling.
|
||||||
|
- Persists configurations and supports restarts/deletions.
|
||||||
|
|
||||||
|
- **Virtual Interfaces** (`virtual_interfaces.js`):
|
||||||
|
- Creates/removes IP aliases on loopback interfaces (e.g., `lo0` on macOS, `lo` on Linux, Windows via netsh).
|
||||||
|
- Assigns IPs from configured subnets (default: `192.168.3.x`).
|
||||||
|
- Supports multi-subnet configuration with CIDR notation.
|
||||||
|
- **Note**: On macOS, if "Stealth Mode" is enabled in System Settings > Network > Firewall Options, ICMP (ping) responses may not work for virtual interfaces. Disable stealth mode or allow ICMP in firewall settings if ping functionality is required.
|
||||||
|
|
||||||
|
- **Certificate Authority** (`certificate_authority.js`):
|
||||||
|
- Generates/installs root CA and domain certificates with SANs.
|
||||||
|
- Supports IP inclusion in SANs for flexible TLS.
|
||||||
|
- Manages certificate expiration monitoring and renewal.
|
||||||
|
- Provides certificate chain fetching for public internet domains.
|
||||||
|
|
||||||
|
- **Plugin System** (`plugin-handler.js`, `plugin-sites/`):
|
||||||
|
- Manages plugins for internal domains (e.g., `peer.directory`).
|
||||||
|
- Serves `https://peer.directory` via the peer.directory plugin with domain listing and search.
|
||||||
|
- Provides plugin lifecycle management (start, stop, restart) via admin interface.
|
||||||
|
- Supports plugin action registration and settings management.
|
||||||
|
- Real-time log viewing for each plugin with xterm.js terminals.
|
||||||
|
|
||||||
|
- **Admin Interface** (`admin.js`, `admin/index.html`):
|
||||||
|
- Web-based panel with real-time WebSocket updates.
|
||||||
|
- Manages domains, Holesail servers/clients, local DNS, certificates, interfaces, logs, settings, backups, diagnostics, stats (including health monitoring), DNS conflict preferences, and plugins.
|
||||||
|
- Plugin management tab with start/stop/restart, action execution, settings configuration, and real-time log viewing.
|
||||||
|
|
||||||
|
- **Domains Management** (`domains.js`):
|
||||||
|
- Adds domains and handles internal domains (e.g., `peer.directory`, `p2ns.admin`).
|
||||||
|
|
||||||
|
- **Maintenance Modules**:
|
||||||
|
- **Backup** (`backup.js`): Automatic and manual backup system with rotation and restore functionality.
|
||||||
|
- **Metrics** (`metrics.js`): System metrics collection, aggregation, and historical data tracking.
|
||||||
|
- **Resource Validation** (`resource_validation.js`): Validates and tracks system resources, peer connections, and cleanup operations. Automatically runs at configurable intervals to detect and clean up stale resources.
|
||||||
|
- **Cleanup** (`cleanup.js`): Closes servers, connections, and interfaces on shutdown.
|
||||||
|
|
||||||
|
- **Infrastructure Modules**:
|
||||||
|
- **Circuit Breaker** (`circuit_breaker.js`): Automatic failure detection and recovery with exponential backoff.
|
||||||
|
- **Rate Limiting** (`rate_limit.js`): API endpoint rate limiting to prevent abuse.
|
||||||
|
- **Error Handler** (`error_handler.js`): Comprehensive error handling with retry logic and graceful degradation.
|
||||||
|
- **Validation** (`validation.js`): Input validation and configuration validation.
|
||||||
|
- **Config** (`config.js`): Configuration validation on startup.
|
||||||
|
|
||||||
|
- **State Management** (`state.js`):
|
||||||
|
- Maintains global state for mappings, configurations, DNS version preferences, metrics, and peer tracking.
|
||||||
|
|
||||||
|
- **Logging** (`logger.js`):
|
||||||
|
- Provides prefixed, leveled logging.
|
||||||
|
|
||||||
|
The system operates in **Master** (initializes core, loads `cache/domains.json` or `DOMAINS_FILE`) or **Joiner** (requests invites) modes.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js**: Version 18 or higher (tested up to 20.x).
|
||||||
|
- **Holesail CLI**: For generating connection hashes (`npm install -g holesail`).
|
||||||
|
- **Dependencies**: `corestore`, `hyperswarm`, `autopass`, `protomux`, `compact-encoding`, `node-forge`, `dns-packet`, `holesail`, `holesail-logger`, `dotenv`, `ws`, `z32`, `hyper-cmd-lib-keys`.
|
||||||
|
- **Platform**: macOS or Linux (sudo required for ports <1024 and interfaces). Windows support planned.
|
||||||
|
- **Optional**: Configuration files (default locations in `cache/` directory): `cache/domains.json`, `cache/local_dns.json`, `cache/holesail_servers.json`, `cache/holesail_clients.json`, `cache/selector_cache.json`, `cache/subscriptions.json`, `cache/peer_history.json`, `cache/peer_metrics.json`, and `.env` for configuration. File paths can be customized via environment variables.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Clone the Repository**:
|
||||||
|
```bash
|
||||||
|
git clone https://git.ssh.surf/snxraven/p2ns.git
|
||||||
|
cd p2ns
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Prepare Configuration Files** (Optional):
|
||||||
|
|
||||||
|
By default, configuration files are stored in the `cache/` directory. You can customize paths via environment variables (see [Environment Variables](#environment-variables)).
|
||||||
|
|
||||||
|
- **cache/domains.json** (or `DOMAINS_FILE`): Pre-load domains for master nodes:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "domain": "example.tld", "hash": "hs://s00084bf87dfa89a3048fb081c0e6207eb5a", "ssl": false },
|
||||||
|
{ "domain": "another.example", "hash": "hs://s000bcc379b38f6d3a5cb4cfde23fa52392d", "ssl": true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
The `ssl` field (boolean, optional) indicates if the Holesail connection uses SSL/TLS. When `true`, proxy connections will use HTTPS/WSS instead of HTTP/WS. Defaults to `false` if not specified.
|
||||||
|
- **cache/local_dns.json** (or `LOCAL_DNS_FILE`): Custom DNS records (supports A, AAAA, CNAME, MX, TXT, SRV, SOA, CAA, NS, PTR, OTHER):
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "name": "local.example", "type": "A", "class": "IN", "ttl": 3600, "data": "192.168.1.100" },
|
||||||
|
{ "name": "mail.example", "type": "MX", "class": "IN", "ttl": 3600, "preference": 10, "exchange": "mx.example.com" },
|
||||||
|
{ "name": "_service._tcp.example", "type": "SRV", "class": "IN", "ttl": 3600, "priority": 0, "weight": 5, "port": 8080, "target": "server.example.com" },
|
||||||
|
{ "name": "example.com", "type": "SOA", "class": "IN", "ttl": 86400, "mname": "ns1.example.com", "rname": "admin.example.com", "serial": 2025090801, "refresh": 3600, "retry": 600, "expire": 604800, "minimum": 3600 },
|
||||||
|
{ "name": "example.com", "type": "CAA", "class": "IN", "ttl": 3600, "flags": 0, "tag": "issue", "value": "letsencrypt.org" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
- **cache/holesail_servers.json** (or `HOLESAIL_SERVERS_FILE`): Persistent Holesail servers:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{ "id": "abc123", "opts": { "name": "server1", "port": 8080, "host": "0.0.0.0", "secure": true, "log": 1 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **cache/holesail_clients.json** (or `HOLESAIL_CLIENTS_FILE`): Persistent Holesail clients:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clients": [
|
||||||
|
{ "id": "def456", "opts": { "domain": "example.tld", "key": "hs://s00084bf...", "port": 8080, "protocol": "tcp" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **cache/selector_cache.json** (or `SELECTOR_CACHE_FILE`): DNS version preferences for domains with both P2P and public records:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"myspace.com": "public",
|
||||||
|
"example.com": "p2p"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Configure Environment** (Optional):
|
||||||
|
Copy `default.env` to `.env` and customize (see [Environment Variables](#environment-variables)).
|
||||||
|
|
||||||
|
## Running the System
|
||||||
|
|
||||||
|
- **Master Node** (initializes network, loads `cache/domains.json` or `DOMAINS_FILE`):
|
||||||
|
```bash
|
||||||
|
sudo node p2ns.js --master
|
||||||
|
```
|
||||||
|
|
||||||
|
Master nodes have enhanced peer management capabilities:
|
||||||
|
- **Proactive Invite Sending**: Automatically sends invites to peers when they connect, eliminating the need for peers to request invites.
|
||||||
|
- **Automatic Reconnection**: Actively attempts to reconnect to disconnected peers using Hyperswarm's peer discovery mechanisms.
|
||||||
|
- **Reconnection Tracking**: Tracks disconnected peers and attempts reconnection with exponential backoff (configurable via `MASTER_RECONNECT_INTERVAL` and `MASTER_MAX_RECONNECT_ATTEMPTS`).
|
||||||
|
- **Invite After Reconnection**: Automatically sends invites again when peers successfully reconnect.
|
||||||
|
|
||||||
|
- **Joiner Node** (connects to peers via invites):
|
||||||
|
```bash
|
||||||
|
sudo node p2ns.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Clean Storage** (removes `./my-storage` for a fresh start):
|
||||||
|
```bash
|
||||||
|
sudo node p2ns.js --clean [--master]
|
||||||
|
```
|
||||||
|
|
||||||
|
The system binds to:
|
||||||
|
- **UDP 53**: DNS server (unless `DISABLE_DNS_SERVER=true`).
|
||||||
|
- **TCP 443**: HTTPS proxy (localhost, unless `DISABLE_PROXY_SERVER=true`).
|
||||||
|
- **TCP 80**: HTTP redirect (per domain, unless `DISABLE_PROXY_SERVER=true`).
|
||||||
|
- **Internal Ports**: Holesail clients (default: `8080`).
|
||||||
|
|
||||||
|
On startup, P2NS:
|
||||||
|
- Installs a root CA and generates certificates for internal domains (`peer.directory`, `peer.dir`, `p2ns.admin`).
|
||||||
|
- Loads `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) to initialize DNS version preferences for conflicting domains.
|
||||||
|
- Sets up DNS, proxy, and Holesail services.
|
||||||
|
- Loads persisted Holesail servers and clients from `cache/holesail_servers.json` and `cache/holesail_clients.json` (or respective environment variables).
|
||||||
|
- Monitors `cache/domains.json` (or `DOMAINS_FILE`) for changes (Master/Joiner).
|
||||||
|
- Initializes WebSocket for admin interface updates.
|
||||||
|
|
||||||
|
Access the admin interface at `https://p2ns.admin` and the peer directory at `https://peer.directory` (trust the root CA in your browser).
|
||||||
|
|
||||||
|
## Adding Domains
|
||||||
|
|
||||||
|
Domains are added as claims with Holesail hashes:
|
||||||
|
|
||||||
|
1. **Generate a Hash**:
|
||||||
|
```bash
|
||||||
|
holesail --live 80 --public
|
||||||
|
```
|
||||||
|
Output: `Connection hash: hs://<hash>`
|
||||||
|
|
||||||
|
2. **Command Line** (Master only, interactive):
|
||||||
|
After starting P2NS, you can use the interactive command line interface to add domains. Type:
|
||||||
|
```
|
||||||
|
add example.tld hs://<hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
The command format is: `add <domain> <hash>`
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- Only available when running as Master node
|
||||||
|
- Must be typed in the terminal where P2NS is running
|
||||||
|
- Interactive input only (not suitable for scripts)
|
||||||
|
- For programmatic access, use the admin API or edit `cache/domains.json` directly
|
||||||
|
|
||||||
|
3. **cache/domains.json** (or `DOMAINS_FILE`):
|
||||||
|
Edit `cache/domains.json` and restart or let the watcher reload (Master/Joiner).
|
||||||
|
|
||||||
|
4. **Admin Interface**:
|
||||||
|
Use the "Domains" tab to add domains interactively. When adding a domain, you can check the "This Holesail Connection Uses SSL/TLS" checkbox if the service being tunneled uses SSL/TLS. This will ensure proxy connections use HTTPS/WSS instead of HTTP/WS.
|
||||||
|
|
||||||
|
Domains sync across peers via Autopass, with consensus based on votes (local claims prioritized, ties broken lexically).
|
||||||
|
|
||||||
|
## Consensus and Voting
|
||||||
|
|
||||||
|
P2NS uses a decentralized consensus mechanism to resolve domain claims across the peer network. This ensures that all peers agree on which Holesail hash should be used for each domain, even when multiple peers claim the same domain.
|
||||||
|
|
||||||
|
### How Consensus Works
|
||||||
|
|
||||||
|
**Claims**: When a domain is added, a claim is created in the Autopass ledger with the key format `claim:domain:claimant`, where `claimant` is the public key of the peer making the claim. The claim value is a JSON object containing the Holesail hash, timestamp, and optionally an SSL flag: `{hash, timestamp, ssl}`. The `ssl` field (boolean) indicates if the Holesail connection uses SSL/TLS, which determines whether proxy connections use HTTPS/WSS instead of HTTP/WS.
|
||||||
|
|
||||||
|
**Votes**: Peers vote on claims by creating vote entries with the key format `vote:domain:claimant:voter`. Each peer can vote for one claimant per domain. Votes are stored in the Autopass ledger and synchronized across all peers.
|
||||||
|
|
||||||
|
**Quorum**: For a domain to be resolved, a quorum must be met. The quorum is calculated as:
|
||||||
|
```
|
||||||
|
minVotes = max(CONSENSUS_MIN_VOTES, ceil(activePeers * CONSENSUS_QUORUM_THRESHOLD))
|
||||||
|
```
|
||||||
|
Where `activePeers` includes the local node. This ensures that a sufficient number of peers have participated in the consensus process.
|
||||||
|
|
||||||
|
**Resolution**: A domain is resolved when:
|
||||||
|
1. One claimant has the most votes
|
||||||
|
2. The quorum threshold is met
|
||||||
|
3. The resolved hash is used for DNS resolution and proxying
|
||||||
|
|
||||||
|
### Auto-Voting
|
||||||
|
|
||||||
|
P2NS automatically votes on domain claims based on the following logic:
|
||||||
|
|
||||||
|
1. **Own Claim**: If the local peer has a claim for a domain, it automatically votes for its own claim.
|
||||||
|
2. **Single Claim**: If there is only one claim for a domain, the peer automatically votes for it.
|
||||||
|
3. **Multiple Claims**: If there are multiple claims, the peer uses the tie-breaking strategy (see below) to determine which claimant to vote for.
|
||||||
|
|
||||||
|
Auto-voting runs periodically and when new claims or votes are detected. Peers will also remove votes for other claimants if their voting decision changes.
|
||||||
|
|
||||||
|
### Tie-Breaking Strategies
|
||||||
|
|
||||||
|
When multiple claimants have the same number of votes, a tie-breaking strategy is applied. The strategy is configurable via `CONSENSUS_TIE_BREAKER`:
|
||||||
|
|
||||||
|
- **`timestamp`** (default): Prefers the oldest claim (first-come-first-served). This rewards early domain registration.
|
||||||
|
- **`claimant_age`**: Prefers the claimant with the longest history. If the local peer is a candidate, it is preferred; otherwise falls back to lexicographic ordering.
|
||||||
|
- **`lexicographic`**: Prefers the local peer's claim if it's a candidate; otherwise uses lexicographic (alphabetical) ordering of claimant public keys.
|
||||||
|
|
||||||
|
### Consensus States
|
||||||
|
|
||||||
|
A domain can be in one of several consensus states:
|
||||||
|
|
||||||
|
- **`resolved`**: The domain has been successfully resolved with a clear winner and quorum met.
|
||||||
|
- **`insufficient_quorum`**: There are claims but not enough votes to meet the quorum threshold.
|
||||||
|
- **`tie`**: Multiple claimants have the same number of votes (tie-breaker applied).
|
||||||
|
- **`no_claims`**: No claims exist for this domain.
|
||||||
|
|
||||||
|
Only domains in the `resolved` state will return a hash for DNS resolution. Other states indicate that consensus has not yet been reached.
|
||||||
|
|
||||||
|
### Consensus Configuration
|
||||||
|
|
||||||
|
The consensus mechanism can be configured via environment variables:
|
||||||
|
|
||||||
|
- **`CONSENSUS_QUORUM_THRESHOLD`** (default: `0.5`): The percentage of active peers that must vote to meet quorum (0.0-1.0). For example, `0.5` means 50% of peers must vote.
|
||||||
|
- **`CONSENSUS_MIN_VOTES`** (default: `2`): The minimum number of votes required regardless of peer count. This ensures quorum even in small networks.
|
||||||
|
- **`CONSENSUS_TIE_BREAKER`** (default: `timestamp`): The strategy used to break ties between claimants with equal votes. Options: `timestamp`, `claimant_age`, `lexicographic`.
|
||||||
|
- **`CONSENSUS_VOTE_VALIDATION`** (default: `true`): Whether to validate that votes reference existing claims. Invalid votes are ignored.
|
||||||
|
- **`CONSENSUS_IMMEDIATE_UPDATE`** (default: `true`): Whether to trigger consensus recalculation immediately when claims or votes are added/removed, rather than waiting for the next periodic check.
|
||||||
|
|
||||||
|
### Consensus Metrics
|
||||||
|
|
||||||
|
The system tracks consensus metrics including:
|
||||||
|
- Total resolutions and failures
|
||||||
|
- Quorum failures
|
||||||
|
- Tie resolutions
|
||||||
|
- Vote validation failures
|
||||||
|
- Average votes per domain
|
||||||
|
- Per-domain resolution statistics
|
||||||
|
|
||||||
|
These metrics are available via the admin interface and the `/api/consensus/metrics` API endpoint.
|
||||||
|
|
||||||
|
### Monitoring Consensus
|
||||||
|
|
||||||
|
You can monitor consensus state for domains via:
|
||||||
|
- **Admin Interface**: The Domains tab shows consensus status badges for each domain.
|
||||||
|
- **API**: Use `GET /api/consensus/:domain` to get detailed consensus state for a specific domain.
|
||||||
|
- **Metrics**: Use `GET /api/consensus/metrics` to view overall consensus statistics.
|
||||||
|
|
||||||
|
For more details, see the [REST API documentation](docs/RESTAPI.md#consensus-endpoints).
|
||||||
|
|
||||||
|
## Peer Management
|
||||||
|
|
||||||
|
P2NS tracks peer connections, metrics, and history to provide visibility into the network and enable peer management features.
|
||||||
|
|
||||||
|
### Peer Connection Lifecycle
|
||||||
|
|
||||||
|
When peers connect via Hyperswarm:
|
||||||
|
1. **Connection Established**: Peer connection is detected and tracked
|
||||||
|
2. **Channel Setup**: Protomux channels are created for communication
|
||||||
|
3. **Replication**: Corestore replication begins automatically
|
||||||
|
4. **Invite Exchange**: Peers exchange invites if needed
|
||||||
|
5. **Metrics Tracking**: Connection metrics are recorded (duration, timestamps)
|
||||||
|
6. **Disconnection**: On disconnect, metrics are finalized and stored in history
|
||||||
|
|
||||||
|
### Peer Blocking
|
||||||
|
|
||||||
|
You can block peers to prevent them from connecting:
|
||||||
|
|
||||||
|
- **Via Admin Interface**: Go to the "Peers" tab, find the peer, and click "Block"
|
||||||
|
- **Via API**: `POST /api/peers/:peerId/block`
|
||||||
|
- **Via SDK**: `await sdk.peers.blockPeer('peer-id')`
|
||||||
|
|
||||||
|
Blocked peers:
|
||||||
|
- Are prevented from establishing new connections
|
||||||
|
- Have existing connections closed immediately
|
||||||
|
- Are stored in `cache/blocked_peers.json` (or `BLOCKED_PEERS_FILE`)
|
||||||
|
- Can be unblocked via admin interface, API, or SDK
|
||||||
|
|
||||||
|
### Peer Metrics and History
|
||||||
|
|
||||||
|
P2NS tracks comprehensive peer information:
|
||||||
|
|
||||||
|
- **Connection Metrics**: Number of connections, total duration, average duration, last seen timestamp
|
||||||
|
- **Connection History**: Timestamped log of all connection/disconnection events
|
||||||
|
- **Peer Information**: Peer ID, connection status, uptime, and metadata
|
||||||
|
|
||||||
|
Access peer information via:
|
||||||
|
- **Admin Interface**: "Peers" tab shows all connected peers with metrics and history
|
||||||
|
- **API**: `GET /api/peers` and `GET /api/peers/:peerId`
|
||||||
|
- **SDK**: `sdk.peers.getPeerInfo()`, `sdk.peers.getPeerMetrics()`, `sdk.peers.getPeerHistory()`
|
||||||
|
|
||||||
|
Peer data is persisted in:
|
||||||
|
- `cache/peer_history.json` (or `PEER_HISTORY_FILE`): Connection history
|
||||||
|
- `cache/peer_metrics.json` (or `PEER_METRICS_FILE`): Aggregated metrics
|
||||||
|
- `cache/blocked_peers.json` (or `BLOCKED_PEERS_FILE`): Blocked peer list
|
||||||
|
|
||||||
|
### Duplicate Connection Handling
|
||||||
|
|
||||||
|
P2NS automatically handles duplicate connections from the same peer:
|
||||||
|
- If a valid existing connection exists, new duplicate connections are rejected
|
||||||
|
- If the existing connection is stale (closed), it's cleaned up and the new connection is accepted
|
||||||
|
- This prevents resource leaks and ensures efficient peer management
|
||||||
|
|
||||||
|
## Using the Peer Directory
|
||||||
|
|
||||||
|
Access `https://peer.directory` to:
|
||||||
|
- View all claimed domains (sorted alphabetically).
|
||||||
|
- Search domains in real-time.
|
||||||
|
- Click links to visit domains (e.g., `https://example.tld`).
|
||||||
|
|
||||||
|
Served via the HTTPS proxy with internal resolution.
|
||||||
|
|
||||||
|
## Using the Admin Interface
|
||||||
|
|
||||||
|
The admin interface at `https://p2ns.admin` provides real-time management via WebSocket. Features include:
|
||||||
|
|
||||||
|
- **Domains**: View, search, add, and remove domains. Local claims marked with 🏠. Supports pagination and real-time updates.
|
||||||
|
- **Host**:
|
||||||
|
- **Holesail Servers**: Create, restart, delete, and view logs for servers (name, port, host, key, secure/UDP, log level). Persisted in `cache/holesail_servers.json` (or `HOLESAIL_SERVERS_FILE`).
|
||||||
|
- **Holesail Clients**: Create, restart, delete, and view logs for clients (domain, key, port, service name, protocol). Persisted in `cache/holesail_clients.json` (or `HOLESAIL_CLIENTS_FILE`). Only domains you own are shown in the "Create Client" modal.
|
||||||
|
- **Service Subscription**: Subscribe to services from other domains in the network. Auto-subscribe to all services for a domain, or manage individual service subscriptions. Subscriptions are automatically synchronized when services are added or removed by domain owners.
|
||||||
|
- **Local DNS**: Manage custom DNS records (A, AAAA, CNAME, MX, TXT, SRV, SOA, CAA, NS, PTR, OTHER) with edit/delete options. Persisted in `cache/local_dns.json` (or `LOCAL_DNS_FILE`). Includes DNS Conflict Selector for managing domains with both P2P and public records.
|
||||||
|
- **Entries**: View Autopass ledger entries (claims/votes) with search and pagination.
|
||||||
|
- **Peers**: List connected peers (public keys) with search and pagination.
|
||||||
|
- **Certificates**: Generate, delete, regenerate, and view domain certificates. Manage root CA (regenerate/install).
|
||||||
|
- **Interfaces**: View domain-to-IP mappings and clean up unused interfaces.
|
||||||
|
- **Backups**: Create manual backups, restore from backups, view backup metadata, and manage backup retention. Automatic backups run on a configurable interval.
|
||||||
|
- **Diagnostics**: Network diagnostics tools including DNS lookup, ping, traceroute, connection testing, and bandwidth monitoring. Supports streaming output for real-time results.
|
||||||
|
- **Stats**: Real-time system metrics including request statistics, Holesail connection status, process metrics (CPU, memory), and historical data with configurable time ranges. Includes system health monitoring with service status, dependency checks, and readiness/liveness probe information.
|
||||||
|
- **Logs**: Real-time terminal view of system logs (DEBUG/INFO/WARN/ERROR).
|
||||||
|
- **Settings**: Edit environment variables with persistence to `.env`. Includes subnet configurator for managing multiple subnet ranges with CIDR notation.
|
||||||
|
- **Plugins**: Manage plugins with start/stop/restart capabilities, execute plugin actions, configure plugin settings, and view real-time logs for each plugin. Plugins are automatically discovered from `plugin-sites/` directories. Each plugin card shows status, version, description, registered actions, settings, and a log terminal. Logs are hidden by default and appear when you start/stop/restart a plugin.
|
||||||
|
|
||||||
|
The interface uses Tailwind CSS and xterm.js for a polished, terminal-based experience. Modals handle actions, and notifications show success/errors. For programmatic access, see the [REST API documentation](docs/RESTAPI.md) for details on the backend API endpoints.
|
||||||
|
|
||||||
|
## DNS Conflict Selector
|
||||||
|
|
||||||
|
The DNS Conflict Selector, located in the "Local DNS" tab of the admin interface, allows users to manage domains that have both P2P and public DNS records. This feature addresses scenarios where a domain (e.g., `myspace.com`) is claimed in the P2NS network but also exists in public DNS.
|
||||||
|
|
||||||
|
- **Functionality**:
|
||||||
|
- Displays a table of domains with both P2P and public records, showing the domain name, public IP (from `state.publicIpForDomain`), and current mode (P2P or Public).
|
||||||
|
- Provides a toggle switch to select between P2P (default, resolves to internal IP with TTL 1) and Public (resolves to public IP with TTL 1).
|
||||||
|
- Preferences are stored in `state.versionPreferences` and persisted to `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) for durability across server restarts.
|
||||||
|
- When a preference is set (e.g., `myspace.com: public`), the middleware selection page (`p2p_domains_proxy.js`) is bypassed, and the system uses the specified mode for both DNS resolution and proxying.
|
||||||
|
- If no preference is set, a middleware page prompts the user to choose between P2P and Public versions, setting a cookie and updating `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`).
|
||||||
|
- Supports all DNS record types (A, AAAA, CNAME, MX, TXT, SRV, SOA, CAA, NS, PTR, OTHER) for local DNS records, allowing fine-grained control over resolution.
|
||||||
|
|
||||||
|
- **Usage**:
|
||||||
|
- Navigate to the "Local DNS" tab in the admin interface.
|
||||||
|
- View the "DNS Conflict Selector" section, which lists all domains from `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) and `state.domainsWithBoth` on startup.
|
||||||
|
- Use the search bar (`search-dns-conflicts`) to filter domains by name, version, or public IP.
|
||||||
|
- Toggle the switch to change a domain's mode (e.g., from P2P to Public). Changes are saved instantly to `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) and reflected in DNS responses and proxy routing.
|
||||||
|
- Add/edit/delete custom DNS records in the "Local DNS" section to override P2P or public resolutions (e.g., SRV for service discovery, CAA for certificate authority restrictions).
|
||||||
|
- Remove a domain via the "Domains" tab to clear its preference from `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) and the table.
|
||||||
|
|
||||||
|
- **Persistence**:
|
||||||
|
- Version preferences are stored in `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) (e.g., `{"myspace.com": "public", "example.com": "p2p"}`).
|
||||||
|
- Loaded on server startup into `state.versionPreferences` for immediate use.
|
||||||
|
- Updated automatically when toggling preferences or removing domains.
|
||||||
|
|
||||||
|
- **Behavior**:
|
||||||
|
- **DNS**: Defaults to P2P (internal IP, TTL 1) unless `state.versionPreferences` specifies `public` (public IP, TTL 1). Custom DNS records in `cache/local_dns.json` (or `LOCAL_DNS_FILE`) take precedence.
|
||||||
|
- **Proxying**: Routes to internal IPs for P2P or public IPs for Public mode, bypassing the middleware page when a preference is set.
|
||||||
|
- **Admin Interface**: Displays all cached preferences and local DNS records on startup, ensuring no site visit is required to populate the table.
|
||||||
|
|
||||||
|
- **Testing**:
|
||||||
|
- Create a `cache/selector_cache.json` (or set `SELECTOR_CACHE_FILE`) with entries (e.g., `{"myspace.com": "public"}`).
|
||||||
|
- Add custom DNS records to `cache/local_dns.json` (or `LOCAL_DNS_FILE`) with various types (e.g., SRV, SOA, CAA).
|
||||||
|
- Start the server and access the admin panel's Local DNS tab to verify the table shows all cached domains and records.
|
||||||
|
- Toggle a preference and check that `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) updates and DNS/proxy behavior changes (e.g., `dig @127.0.0.1 myspace.com` returns public IP).
|
||||||
|
- Add/edit/delete a DNS record (e.g., SRV for `_service._tcp.example`) and verify it appears in `cache/local_dns.json` (or `LOCAL_DNS_FILE`) and resolves correctly (`dig @127.0.0.1 _service._tcp.example SRV`).
|
||||||
|
- Remove a domain and confirm its preference is removed from `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) and the table.
|
||||||
|
|
||||||
|
## Hybrid DNS Resolution
|
||||||
|
|
||||||
|
- **P2P Domains**: Resolve to local IPs, start Holesail tunnels.
|
||||||
|
- **Public Domains**: Forward to `PUBLIC_DNS_SERVER` (default: `1.1.1.1`) with TTL 1 for domains with both records when public mode is selected.
|
||||||
|
- **Local DNS Records**: Served from `cache/local_dns.json` (or `LOCAL_DNS_FILE`) with support for all record types (A, AAAA, CNAME, MX, TXT, SRV, SOA, CAA, NS, PTR, OTHER).
|
||||||
|
- **Internal Domains**: Map to `127.0.0.1`.
|
||||||
|
- **Conflicting Domains**: Managed via the DNS Conflict Selector, with preferences stored in `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`).
|
||||||
|
|
||||||
|
Test with:
|
||||||
|
```bash
|
||||||
|
dig @127.0.0.1 example.tld
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proxying and Tunneling
|
||||||
|
|
||||||
|
- **HTTPS Proxy**: Routes requests to local IPs via Holesail (port 443, localhost) or public IPs based on DNS Conflict Selector preferences.
|
||||||
|
- **HTTP Redirect**: Redirects to HTTPS (port 80, per domain).
|
||||||
|
- **TLS Proxy**: Per-domain TLS servers with SNI support.
|
||||||
|
- **Holesail Clients**: Start lazily, timeout after `HOLESAIL_TIMEOUT` (default: 5 minutes) unless persistent. Support both TCP and UDP protocols. Clients created for subscribed services are automatically named using the format `domain_servicename` to avoid naming conflicts.
|
||||||
|
- **Holesail Servers**: Run persistently, configurable via admin interface.
|
||||||
|
- **WebSocket Support**: Handles upgrades for real-time applications.
|
||||||
|
|
||||||
|
## Service Subscriptions
|
||||||
|
|
||||||
|
P2NS supports automatic subscription to services published by domain owners across the network. This feature allows peers to automatically create Holesail clients for services they want to access, with dynamic synchronization when services are added or removed.
|
||||||
|
|
||||||
|
### Domain Ownership
|
||||||
|
|
||||||
|
Domain ownership is determined by consensus: a domain is considered "owned" by a peer if that peer is the resolved claimant for the domain (i.e., their claim has the most votes and meets quorum). Only domains you own are shown in the "Create Client" modal, ensuring you can only create clients for domains you control.
|
||||||
|
|
||||||
|
### Service Records in Claims
|
||||||
|
|
||||||
|
Domain claim records now include a `clients` array that lists all services configured for that domain. Each service entry contains:
|
||||||
|
- `serviceName`: Unique identifier for the service
|
||||||
|
- `key`: Holesail connection hash (`hs://...`)
|
||||||
|
- `port`: Port number for the service
|
||||||
|
- `protocol`: Either `tcp` or `udp`
|
||||||
|
|
||||||
|
The claim record structure is: `{hash, clients: [...], timestamp, ssl}`. This allows multiple services per domain while maintaining a single claim record. The `ssl` field (boolean, optional) indicates if the Holesail connection uses SSL/TLS for secure tunneling.
|
||||||
|
|
||||||
|
### Service Subscription Features
|
||||||
|
|
||||||
|
- **Service Subscription Modal**: Accessible from the "Host" tab via the "Service Subscription" button. Lists all domains in the network with their available services, searchable by domain name.
|
||||||
|
- **Individual Service Subscription**: Subscribe to specific services from any domain. Creates a Holesail client automatically with the naming format `domain_servicename`.
|
||||||
|
- **Subscribe All**: Per-domain option to automatically subscribe to all services for that domain. When enabled, new services added by the domain owner are automatically subscribed to by all peers with this option enabled.
|
||||||
|
- **Protocol Selection**: When creating clients manually or subscribing to services, you can specify the protocol (TCP or UDP). Defaults to TCP if not specified.
|
||||||
|
- **Auto-Subscription on Bootup**: On system startup, all subscribed services automatically create their Holesail clients (unless `DISABLE_AUTO_SUBSCRIPTION` is enabled).
|
||||||
|
- **Dynamic Synchronization**: The system automatically:
|
||||||
|
- **Unsubscribes** from services when they are removed from a domain's claim record by the domain owner
|
||||||
|
- **Subscribes** to new services when they are added to a domain's claim record, if "subscribe all" is enabled for that domain
|
||||||
|
- Monitors AutoPass `update` and `append` events to detect claim changes
|
||||||
|
- Uses debouncing to prevent excessive processing during rapid updates
|
||||||
|
|
||||||
|
### Subscription Storage
|
||||||
|
|
||||||
|
Subscriptions are persisted in `cache/subscriptions.json` with the following structure:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"domain": "example.tld",
|
||||||
|
"subscribeAll": true,
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"serviceName": "web",
|
||||||
|
"key": "hs://s00084bf...",
|
||||||
|
"port": 8080,
|
||||||
|
"protocol": "tcp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `subscribeAll` flag indicates whether to automatically subscribe to all services for that domain. Individual service subscriptions are stored in the `services` array.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- **`DISABLE_AUTO_SUBSCRIPTION`**: Set to `true` to disable automatic starting of Holesail clients for subscribed services on bootup or when new services are detected. Default: `false`.
|
||||||
|
- **`SUBSCRIPTIONS_FILE`**: Path to subscriptions storage file. Default: `./cache/subscriptions.json`.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
1. **Subscribing to Services**:
|
||||||
|
- Open the "Service Subscription" modal from the "Host" tab
|
||||||
|
- Search for domains or browse the list
|
||||||
|
- Click "Subscribe" next to a service to subscribe individually
|
||||||
|
- Click "Subscribe to All" to enable automatic subscription to all services for that domain
|
||||||
|
|
||||||
|
2. **Managing Subscriptions**:
|
||||||
|
- View all subscriptions in the Service Subscription modal
|
||||||
|
- Unsubscribe from individual services or disable "subscribe all" for a domain
|
||||||
|
- Subscriptions are automatically synchronized when domain owners add or remove services
|
||||||
|
|
||||||
|
3. **Creating Clients Manually**:
|
||||||
|
- Use the "Create Client" button in the "Host" tab
|
||||||
|
- Only domains you own are shown in the domain list
|
||||||
|
- Specify service name and protocol (TCP/UDP) when creating
|
||||||
|
- The client ID will be `domain_servicename` for subscribed services, or a generated ID for manual clients
|
||||||
|
|
||||||
|
## Certificate Authority and TLS
|
||||||
|
|
||||||
|
- **Root CA**: Generated in `./certs/ca.cert.pem`, installed on macOS/Linux (System Keychain or `/usr/local/share/ca-certificates`).
|
||||||
|
- **Domain Certs**: Created in `./certs/<domain>/` with SANs for domain and IPs.
|
||||||
|
- **SNI Support**: Dynamic certificate selection for HTTPS proxy.
|
||||||
|
- **Auto-Install**: Checks/reinstalls CA if expired; supports macOS, Linux, and Windows (via `certutil`).
|
||||||
|
- **Conflict Handling**: Bypasses middleware page for domains with preferences in `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`).
|
||||||
|
|
||||||
|
### Manual Root CA Installation
|
||||||
|
|
||||||
|
If automatic SSL certificate installation fails, you can manually install the root CA certificate. The certificate is located at `./certs/ca.cert.pem` (or `CERTS_DIR/ca.cert.pem` if `CERTS_DIR` is set in your `.env`).
|
||||||
|
|
||||||
|
#### Operating System Level Installation
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
1. Open **Keychain Access** (Applications > Utilities > Keychain Access).
|
||||||
|
2. Select **System** keychain from the left sidebar.
|
||||||
|
3. Go to **File** > **Import Items...** (or press `Cmd+O`).
|
||||||
|
4. Navigate to `./certs/ca.cert.pem` and select it.
|
||||||
|
5. Find "P2NS CA" in the list, double-click it.
|
||||||
|
6. Expand **Trust** and set "When using this certificate" to **Always Trust**.
|
||||||
|
7. Close the dialog and enter your password when prompted.
|
||||||
|
|
||||||
|
Alternatively, via command line (requires sudo):
|
||||||
|
```bash
|
||||||
|
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./certs/ca.cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux:**
|
||||||
|
1. Copy the certificate to the system certificates directory:
|
||||||
|
```bash
|
||||||
|
sudo cp ./certs/ca.cert.pem /usr/local/share/ca-certificates/p2ns-ca.crt
|
||||||
|
```
|
||||||
|
2. Update the certificate store:
|
||||||
|
```bash
|
||||||
|
sudo update-ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
1. Open Command Prompt as Administrator.
|
||||||
|
2. Navigate to your P2NS directory:
|
||||||
|
```cmd
|
||||||
|
cd C:\path\to\p2ns
|
||||||
|
```
|
||||||
|
3. Install the certificate:
|
||||||
|
```cmd
|
||||||
|
certutil -addstore -f "ROOT" certs\ca.cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Browser-Level Installation
|
||||||
|
|
||||||
|
Even after OS-level installation, some browsers maintain their own certificate stores and may require separate installation:
|
||||||
|
|
||||||
|
**Chrome/Edge (Chromium-based):**
|
||||||
|
- Chrome and Edge use the OS certificate store on macOS and Windows, so OS-level installation should be sufficient.
|
||||||
|
- On Linux, Chrome may use its own store. If issues persist:
|
||||||
|
1. Open Chrome settings (`chrome://settings/security`).
|
||||||
|
2. Click **Manage certificates**.
|
||||||
|
3. Go to **Authorities** tab.
|
||||||
|
4. Click **Import** and select `./certs/ca.cert.pem`.
|
||||||
|
5. Check **Trust this certificate for identifying websites** and click **OK**.
|
||||||
|
|
||||||
|
**Firefox:**
|
||||||
|
Firefox uses its own certificate store and requires separate installation:
|
||||||
|
1. Open Firefox preferences (`about:preferences#privacy` or `about:preferences#security`).
|
||||||
|
2. Scroll down to **Certificates** section.
|
||||||
|
3. Click **View Certificates**.
|
||||||
|
4. Go to **Authorities** tab.
|
||||||
|
5. Click **Import...**.
|
||||||
|
6. Navigate to `./certs/ca.cert.pem` and select it.
|
||||||
|
7. Check **Trust this CA to identify websites** and click **OK**.
|
||||||
|
|
||||||
|
**Safari (macOS):**
|
||||||
|
- Safari uses the macOS System Keychain, so OS-level installation should be sufficient.
|
||||||
|
- If issues persist, verify the certificate is trusted in Keychain Access (see macOS instructions above).
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
After installation, verify the certificate is trusted by accessing `https://p2ns.admin` or `https://peer.directory` in your browser. You should see a valid TLS connection without certificate warnings.
|
||||||
|
|
||||||
|
## Logging and Debugging
|
||||||
|
|
||||||
|
Logs are prefixed (e.g., `[Main]`, `[DNS]`) and leveled (DEBUG=0, INFO=1, WARN=2, ERROR=3). Set `LOG_LEVEL` in `.env`. Logs are displayed in the admin interface and console.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
[INFO Main] Starting main function...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Configure via `.env` (copy from `default.env`):
|
||||||
|
|
||||||
|
- **STORAGE_DIR**: Corestore data directory (default: `./my-storage`).
|
||||||
|
- **DOMAINS_FILE**: Domains JSON file (default: `./cache/domains.json`).
|
||||||
|
- **LOCAL_DNS_FILE**: Local DNS records file (default: `cache/local_dns.json`).
|
||||||
|
- **HOLESAIL_SERVERS_FILE**: Holesail servers file (default: `./cache/holesail_servers.json`).
|
||||||
|
- **HOLESAIL_CLIENTS_FILE**: Holesail clients file (default: `./cache/holesail_clients.json`).
|
||||||
|
- **SELECTOR_CACHE_FILE**: DNS version preferences file (default: `./cache/selector_cache.json`).
|
||||||
|
- **SUBSCRIPTIONS_FILE**: Path to subscriptions storage file (default: `./cache/subscriptions.json`).
|
||||||
|
- **PEER_HISTORY_FILE**: Path to peer history storage file (default: `./cache/peer_history.json`).
|
||||||
|
- **PEER_METRICS_FILE**: Path to peer metrics storage file (default: `./cache/peer_metrics.json`).
|
||||||
|
- **TOPIC_SEED**: Hyperswarm topic seed (default: `p2ns-dns`).
|
||||||
|
- **CERTS_DIR**: Certificate storage directory (default: `./certs`).
|
||||||
|
- **INTERNAL_PORT**: Holesail client port (default: `8080`).
|
||||||
|
- **SUBNET_BASE**: Virtual interface IP base (default: `192.168.3`). Deprecated in favor of `SUBNETS` array.
|
||||||
|
- **INITIAL_IP_INDEX**: Starting IP index (default: `2`). Deprecated in favor of `SUBNETS` array.
|
||||||
|
- **SUBNETS**: JSON array of subnet configurations with `base`, `cidr`, `startIndex`, and optional `name` (default: single subnet from `SUBNET_BASE`). Example: `[{"base":"192.168.3.0","cidr":24,"startIndex":2,"name":"Primary Subnet"}]`.
|
||||||
|
- **SUBNET_NAME**: Interface for virtual IPs (default: `lo0` on macOS, `lo` on Linux, auto-detected on Windows). Can be configured via the admin interface Settings tab, which provides a dropdown list of all available network interfaces on the system. **Note**: On macOS, if "Stealth Mode" is enabled in firewall settings, ICMP responses for virtual interfaces may be blocked.
|
||||||
|
- **PUBLIC_DNS_SERVER**: Public DNS fallback server(s). Supports comma-separated list for failover (default: `1.1.1.1`). Example: `1.1.1.1,8.8.8.8,9.9.9.9`. The system will try each server in order until one succeeds. Can be managed via the admin interface Settings tab.
|
||||||
|
- **HOLESAIL_TIMEOUT**: Holesail client timeout in minutes (default: `5` minutes).
|
||||||
|
- **PORT_CHECK_TIMEOUT**: Port check timeout in seconds (default: `2` seconds).
|
||||||
|
- **LOG_LEVEL**: Logging level (0-3, default: `0`).
|
||||||
|
- **Internal Domains**: Automatically discovered from `plugin-sites/{domain}/config.json` files. `p2ns.admin` is always internal.
|
||||||
|
- **DNS_PORT**: DNS server port (default: `53`).
|
||||||
|
- **HTTPS_PORT**: HTTPS proxy port (default: `443`).
|
||||||
|
- **HTTP_PORT**: HTTP redirect port (default: `80`).
|
||||||
|
- **DISABLE_DNS_SERVER**: Disable DNS server (default: `false`).
|
||||||
|
- **DISABLE_PROXY_SERVER**: Disable proxy servers (default: `false`).
|
||||||
|
- **DISABLE_VIRTUAL_INTERFACES**: Disable virtual interface creation (default: `false`).
|
||||||
|
- **ALLOW_ANY_WRITER_INVITES**: Allow joiners to issue invites (default: `false`).
|
||||||
|
- **CONSENSUS_QUORUM_THRESHOLD**: Percentage of active peers that must vote to meet quorum (0.0-1.0, default: `0.5`). For example, `0.5` means 50% of peers must vote.
|
||||||
|
- **CONSENSUS_MIN_VOTES**: Minimum number of votes required regardless of peer count (default: `2`). Ensures quorum even in small networks.
|
||||||
|
- **CONSENSUS_TIE_BREAKER**: Strategy for breaking ties between claimants with equal votes (default: `timestamp`). Options: `timestamp` (prefer oldest claim), `claimant_age` (prefer longest history), `lexicographic` (alphabetical ordering).
|
||||||
|
- **CONSENSUS_VOTE_VALIDATION**: Whether to validate that votes reference existing claims (default: `true`). Invalid votes are ignored when enabled.
|
||||||
|
- **RESOURCE_VALIDATION_INTERVAL**: Resource validation interval in minutes (default: `5`). The system periodically validates and cleans up stale resources like closed connections, inactive servers, and orphaned timeouts.
|
||||||
|
- **CONSENSUS_IMMEDIATE_UPDATE**: Whether to trigger consensus recalculation immediately when claims or votes change (default: `true`). When `false`, waits for next periodic check.
|
||||||
|
- **FULL_PERSISTENCE**: Keep Holesail connections persistent indefinitely (default: `false`).
|
||||||
|
- **BACKUP_DIR**: Backup storage directory (default: `./backups`).
|
||||||
|
- **BACKUP_RETENTION**: Number of backups to keep before rotation (default: `25`).
|
||||||
|
- **BACKUP_INTERVAL**: Automatic backup interval in minutes (default: `720` = 12 hours).
|
||||||
|
- **METRICS_RETENTION_MS**: Metrics data retention period in minutes (default: `60` = 1 hour).
|
||||||
|
- **METRICS_SAMPLING_RATE**: Metrics sampling rate (0.0-1.0, default: `1.0` for all samples).
|
||||||
|
- **METRICS_AGGREGATION_INTERVAL**: Metrics aggregation interval in seconds (default: `60` = 1 minute).
|
||||||
|
- **METRICS_MAX_BUFFER_SIZE**: Maximum number of metric samples to buffer (default: `1000`).
|
||||||
|
- **DISABLE_AUTO_SUBSCRIPTION**: Disable automatic starting of Holesail clients for subscribed services on bootup or when new services are detected (default: `false`).
|
||||||
|
|
||||||
|
#### Master Node Settings
|
||||||
|
|
||||||
|
These settings only apply when running with the `--master` flag:
|
||||||
|
|
||||||
|
- **MASTER_RECONNECT_INTERVAL**: Base interval in seconds for reconnection attempts when peers disconnect (default: `5`). Master nodes will attempt to reconnect to disconnected peers with exponential backoff. The delay between attempts increases exponentially (5s, 10s, 20s, etc.) up to a maximum of 60 seconds.
|
||||||
|
- **MASTER_MAX_RECONNECT_ATTEMPTS**: Maximum number of reconnection attempts per peer before giving up (default: `10`). After this limit is reached, the peer is removed from the reconnection tracking set.
|
||||||
|
- **MASTER_PROACTIVE_INVITE_DELAY**: Delay in milliseconds before sending proactive invite to new peers (default: `500`). Master nodes automatically send invites to peers when they connect, ensuring new peers can join the network immediately without needing to request an invite.
|
||||||
|
|
||||||
|
**Master Node Behavior:**
|
||||||
|
- **Proactive Invite Sending**: Master nodes automatically send invites to peers when they connect, eliminating the need for peers to request invites.
|
||||||
|
- **Automatic Reconnection**: When a peer disconnects, the master node actively attempts to reconnect using Hyperswarm's peer discovery mechanisms.
|
||||||
|
- **Reconnection Tracking**: Master nodes track disconnected peers and attempt reconnection with exponential backoff to avoid overwhelming the network.
|
||||||
|
- **Invite After Reconnection**: When a peer successfully reconnects, the master node automatically sends an invite again to ensure the peer can rejoin the network.
|
||||||
|
|
||||||
|
Example `.env`:
|
||||||
|
```env
|
||||||
|
LOG_LEVEL=1
|
||||||
|
STORAGE_DIR=./my-storage
|
||||||
|
# Internal domains are automatically discovered from plugin-sites/{domain}/config.json
|
||||||
|
# p2ns.admin is always treated as an internal domain
|
||||||
|
SUBNETS=[{"base":"192.168.3.0","cidr":24,"startIndex":2,"name":"Primary Subnet"}]
|
||||||
|
SELECTOR_CACHE_FILE=cache/selector_cache.json
|
||||||
|
BACKUP_INTERVAL=720
|
||||||
|
METRICS_RETENTION_MS=60
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup and Recovery
|
||||||
|
|
||||||
|
P2NS includes an automatic backup system that periodically saves critical configuration files and data. Backups are stored in the directory specified by `BACKUP_DIR` (default: `./backups`).
|
||||||
|
|
||||||
|
### Automatic Backups
|
||||||
|
|
||||||
|
- Backups run automatically at intervals specified by `BACKUP_INTERVAL` in minutes (default: 720 minutes = 12 hours).
|
||||||
|
- The system maintains up to `BACKUP_RETENTION` backups (default: 25), automatically rotating older backups.
|
||||||
|
- Each backup includes:
|
||||||
|
- Configuration files (default locations: `cache/domains.json`, `cache/local_dns.json`, `cache/holesail_servers.json`, `cache/holesail_clients.json`, `cache/selector_cache.json`, `cache/subscriptions.json`, `cache/peer_history.json`, `cache/peer_metrics.json`)
|
||||||
|
- Certificate directory (`certs/` or `CERTS_DIR`)
|
||||||
|
- Cache directory (`cache/`)
|
||||||
|
- Metadata including timestamp, file list, and sizes
|
||||||
|
|
||||||
|
### Manual Backups
|
||||||
|
|
||||||
|
Create manual backups via:
|
||||||
|
- **Admin Interface**: Use the "Backups" tab to create, restore, view metadata, or delete backups.
|
||||||
|
- **API**: `POST /api/backups/create` endpoint.
|
||||||
|
|
||||||
|
### Restoring Backups
|
||||||
|
|
||||||
|
Restore backups via:
|
||||||
|
- **Admin Interface**: Select a backup from the list and click "Restore".
|
||||||
|
- **API**: `POST /api/backups/restore` with the backup name.
|
||||||
|
|
||||||
|
Restoration replaces current configuration files and certificates with the backup contents. The system should be restarted after restoration for changes to take full effect.
|
||||||
|
|
||||||
|
## Health Checks and Diagnostics
|
||||||
|
|
||||||
|
P2NS provides comprehensive health monitoring and diagnostic tools accessible via the admin interface and API.
|
||||||
|
|
||||||
|
### Health Endpoints
|
||||||
|
|
||||||
|
- **Liveness Probe**: `GET /api/health` - Checks if the process is alive and basic services are running.
|
||||||
|
- **Readiness Probe**: `GET /api/health?probe=readiness` - Checks if all services are ready to accept traffic.
|
||||||
|
|
||||||
|
Health status includes:
|
||||||
|
- Service status (DNS, Proxy, Swarm)
|
||||||
|
- Dependency health (Corestore, Hyperswarm)
|
||||||
|
- Connected peers count
|
||||||
|
- System uptime
|
||||||
|
|
||||||
|
### Diagnostic Tools
|
||||||
|
|
||||||
|
Available via the "Diagnostics" tab in the admin interface or API endpoints:
|
||||||
|
|
||||||
|
- **DNS Lookup** (`POST /api/diagnostics/dns-lookup`): Resolve DNS records (A, AAAA, MX, TXT, NS, CNAME, SRV, PTR, SOA).
|
||||||
|
- **Ping** (`POST /api/diagnostics/ping`): Test network connectivity with configurable packet count. Supports streaming output for real-time results.
|
||||||
|
- **Traceroute** (`POST /api/diagnostics/traceroute`): Trace network path to a target. Supports streaming output.
|
||||||
|
- **Connection Test** (`POST /api/diagnostics/connection-test`): Test TCP connectivity to a domain and port.
|
||||||
|
- **Bandwidth** (`GET /api/diagnostics/bandwidth`): View network interface information and configuration.
|
||||||
|
|
||||||
|
All diagnostic tools support both streaming (real-time) and non-streaming modes, with detailed error reporting and response time metrics.
|
||||||
|
|
||||||
|
## Resource Validation and Cleanup
|
||||||
|
|
||||||
|
P2NS includes an automatic resource validation system that periodically checks and cleans up stale resources to maintain system health and prevent resource leaks.
|
||||||
|
|
||||||
|
### Resource Validation System
|
||||||
|
|
||||||
|
The resource validation system runs at configurable intervals (default: 5 minutes, set via `RESOURCE_VALIDATION_INTERVAL`) and validates:
|
||||||
|
|
||||||
|
- **Holesail Connections**: Detects and removes stale Holesail server/client connections
|
||||||
|
- **TLS/HTTP Servers**: Identifies and closes inactive TLS and HTTP server instances
|
||||||
|
- **Peer Channels**: Validates Protomux channels and removes closed or stale channels
|
||||||
|
- **Timeout Handles**: Cleans up orphaned timeout handles that are no longer needed
|
||||||
|
- **State Maps**: Ensures state maps (`holesails`, `tlsServers`, `httpServers`, `holesailClientTimeouts`) are consistent
|
||||||
|
|
||||||
|
### What Gets Validated
|
||||||
|
|
||||||
|
1. **Holesail Servers/Clients**: Checks if processes are still running and connections are active
|
||||||
|
2. **TLS/HTTP Servers**: Verifies servers are still listening and haven't been closed unexpectedly
|
||||||
|
3. **Peer Channels**: Validates that channels are still open and connected
|
||||||
|
4. **Timeouts**: Removes timeout handles for connections that no longer exist
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- **RESOURCE_VALIDATION_INTERVAL**: Interval in minutes between validation runs (default: `5`)
|
||||||
|
- Validation runs automatically in the background
|
||||||
|
- No manual intervention required
|
||||||
|
- Logs validation actions for debugging
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
- **Prevents Resource Leaks**: Automatically cleans up stale resources
|
||||||
|
- **Maintains System Health**: Ensures state maps stay consistent
|
||||||
|
- **Improves Performance**: Removes unused connections and servers
|
||||||
|
- **Reduces Memory Usage**: Cleans up orphaned objects and handles
|
||||||
|
|
||||||
|
## System Metrics and Monitoring
|
||||||
|
|
||||||
|
P2NS collects comprehensive system metrics for monitoring and troubleshooting.
|
||||||
|
|
||||||
|
### Metrics Collection
|
||||||
|
|
||||||
|
- **Real-time Metrics**: Current system state including request statistics, Holesail connections, process metrics (CPU, memory), and peer information.
|
||||||
|
- **Historical Metrics**: Time-series data with configurable retention (`METRICS_RETENTION_MS`, default: 1 hour).
|
||||||
|
- **Sampling**: Configurable sampling rate (`METRICS_SAMPLING_RATE`, default: 1.0 for all samples).
|
||||||
|
- **Aggregation**: Automatic aggregation at intervals (`METRICS_AGGREGATION_INTERVAL`, default: 1 minute).
|
||||||
|
|
||||||
|
### Metrics Endpoints
|
||||||
|
|
||||||
|
- **Current Stats**: `GET /api/stats` - Real-time system metrics including:
|
||||||
|
- Request statistics (total, success rate, average response time)
|
||||||
|
- Holesail server/client status and resource usage
|
||||||
|
- P2P domain connections
|
||||||
|
- Process metrics (CPU, memory)
|
||||||
|
- Peer connection metrics
|
||||||
|
|
||||||
|
- **Historical Data**: `GET /api/stats/historical?minutes=N` - Historical metrics for the specified time range (1-1440 minutes).
|
||||||
|
|
||||||
|
### Metrics Configuration
|
||||||
|
|
||||||
|
Configure metrics behavior via environment variables:
|
||||||
|
- `METRICS_RETENTION_MS`: How long to keep historical data
|
||||||
|
- `METRICS_SAMPLING_RATE`: Fraction of requests to sample (0.0-1.0)
|
||||||
|
- `METRICS_AGGREGATION_INTERVAL`: Aggregation frequency
|
||||||
|
- `METRICS_MAX_BUFFER_SIZE`: Maximum samples to buffer
|
||||||
|
|
||||||
|
View metrics in the admin interface "Stats" tab with interactive charts and real-time updates.
|
||||||
|
|
||||||
|
## Subnet Configuration
|
||||||
|
|
||||||
|
P2NS supports flexible subnet configuration for IP allocation to domains. You can configure multiple subnets with CIDR notation for better IP management.
|
||||||
|
|
||||||
|
### Single Subnet (Legacy)
|
||||||
|
|
||||||
|
The legacy `SUBNET_BASE` and `INITIAL_IP_INDEX` variables configure a single subnet:
|
||||||
|
- `SUBNET_BASE`: Base IP address (e.g., `192.168.3`)
|
||||||
|
- `INITIAL_IP_INDEX`: Starting IP index (e.g., `2` for `192.168.3.2`)
|
||||||
|
|
||||||
|
### Multi-Subnet Configuration
|
||||||
|
|
||||||
|
Use the `SUBNETS` environment variable (JSON array) for advanced subnet configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"base": "192.168.3.0",
|
||||||
|
"cidr": 24,
|
||||||
|
"startIndex": 2,
|
||||||
|
"name": "Primary Subnet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"base": "10.0.0.0",
|
||||||
|
"cidr": 24,
|
||||||
|
"startIndex": 2,
|
||||||
|
"name": "Secondary Subnet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Each subnet object contains:
|
||||||
|
- **base**: Network base IP address (e.g., `192.168.3.0`)
|
||||||
|
- **cidr**: CIDR notation (1-32, typically 24 for `/24` networks)
|
||||||
|
- **startIndex**: First usable IP index (1-254, typically 2 to avoid gateway)
|
||||||
|
- **name**: Optional subnet name/description
|
||||||
|
|
||||||
|
### Subnet Management
|
||||||
|
|
||||||
|
- **Admin Interface**: Use the "Settings" tab's subnet configurator to view and manage subnets.
|
||||||
|
- **API**:
|
||||||
|
- `GET /api/subnets` - List all configured subnets with capacity information
|
||||||
|
- `POST /api/subnets` - Update subnet configuration (requires restart)
|
||||||
|
|
||||||
|
The system automatically assigns IPs from available subnets, tracking usage and capacity. Subnet changes require a system restart to take full effect.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Port Conflicts**: Use `netstat -an | grep LISTEN` or `lsof -i :53` to check ports. Set `DISABLE_DNS_SERVER` or `DISABLE_PROXY_SERVER` to `true`.
|
||||||
|
- **CA Installation**: Verify in Keychain Access (macOS) or `/usr/local/share/ca-certificates` (Linux). Import manually if needed.
|
||||||
|
- **Holesail Failures**: Test hashes with `holesail --live 80 --public`. Ensure CLI is installed.
|
||||||
|
- **DNS Errors**: Check logs in admin interface or console. Verify peer connections, `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`), and `cache/local_dns.json` (or `LOCAL_DNS_FILE`) for conflicting domains and custom records.
|
||||||
|
- **Interface Issues**: Requires sudo; check with `ifconfig lo0` or `ip addr show lo`.
|
||||||
|
- **ICMP/Ping Not Working on macOS**: If virtual interfaces don't respond to ping, check if "Stealth Mode" is enabled in System Settings > Network > Firewall Options. Stealth mode blocks ICMP responses. Disable it or configure firewall to allow ICMP if ping functionality is needed.
|
||||||
|
- **Admin Interface**: Ensure `p2ns.admin` resolves and WebSocket connects (`wss://p2ns.admin/ws`). Check DNS Conflict Selector table loads all cached domains and DNS records on startup.
|
||||||
|
- **Sync Issues**: Use `--clean` to reset storage. Monitor `[Swarm]` logs for peer events.
|
||||||
|
- **DNS Conflict Selector**: If domains or records don't appear in the table, verify `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) and `cache/local_dns.json` (or `LOCAL_DNS_FILE`). Check logs for errors in `/api/local-dns` or `/api/selector-cache`.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **P2P Exposure**: Hyperswarm uses public keys; join trusted networks only.
|
||||||
|
- **CA Security**: Protect `./certs` as root CA enables local MITM.
|
||||||
|
- **Consensus**: Voting prevents tampering, but monitor for claim disputes.
|
||||||
|
- **Sudo**: Run in isolated environments due to privileged access.
|
||||||
|
- **Dependencies**: Audit `node-forge`, `holesail`, and others for vulnerabilities.
|
||||||
|
- **Selector Cache and DNS Records**: Ensure `cache/selector_cache.json` (or `SELECTOR_CACHE_FILE`) and `cache/local_dns.json` (or `LOCAL_DNS_FILE`) have restricted permissions, as they control DNS routing.
|
||||||
|
|
||||||
|
## Recent Enhancements
|
||||||
|
|
||||||
|
- **Certificate Security**: Automatic expiration monitoring and renewal, certificate revocation list (CRL) support, improved certificate details modal UI
|
||||||
|
- **Backup and Recovery**: Automatic backups with rotation, restore functionality, scheduled backups
|
||||||
|
- **Enhanced Error Recovery**: Retry logic with exponential backoff, automatic reconnection, circuit breaker pattern, graceful degradation
|
||||||
|
- **Health Check Enhancements**: Detailed service health checks, readiness/liveness probes, dependency health monitoring
|
||||||
|
- **Metrics Optimization**: Metric aggregation, configurable sampling, retention periods, optimized memory usage
|
||||||
|
- **Windows Support**: Full Windows support for virtual interfaces (netsh) and CA installation (certutil)
|
||||||
|
- **API Documentation**: OpenAPI/Swagger specification, usage examples, troubleshooting guide
|
||||||
|
|
||||||
|
## Additional Documentation
|
||||||
|
|
||||||
|
For more detailed information on specific topics, see:
|
||||||
|
|
||||||
|
- **[Plugin System](docs/plugins/README.md)**: Complete guide to creating and managing plugins
|
||||||
|
- **[Plugin SDK Reference](docs/plugins/PLUGIN_SDK.md)**: Comprehensive API reference for plugin development
|
||||||
|
- **[REST API Documentation](docs/RESTAPI.md)**: Complete API endpoint reference including plugin management endpoints
|
||||||
|
- **[HyperDB Integration](docs/plugins/HYPERDB.md)**: Database operations for plugins
|
||||||
|
- **[Plugin Channels](docs/plugins/PLUGIN_CHANNELS.md)**: P2P communication via Protomux channels
|
||||||
|
- **[Hyperdrive Integration](docs/plugins/HYPERDRIVE.md)**: Distributed file system for plugins
|
||||||
|
- **[Proxy Server Documentation](proxy-server/README.md)**: Standalone proxy server configuration and usage
|
||||||
|
- **[Test Scripts](test-scripts/README.md)**: HyperDB replication and management test scripts
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Fork, branch, and submit PRs for bug fixes, features, or docs. Test on macOS/Linux. Focus on stability and cross-platform compatibility.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# P2NS Service Subscriptions
|
||||||
|
|
||||||
|
Service subscriptions allow peers to automatically create Holesail clients for services published by domain owners across the network.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Domain owners can publish multiple services per domain. Other peers can subscribe to these services, and Holesail clients are automatically created and managed.
|
||||||
|
|
||||||
|
## Domain Ownership
|
||||||
|
|
||||||
|
A domain is "owned" by the peer whose claim has the most votes and meets quorum (see [CONSENSUS.md](CONSENSUS.md)). Only owned domains appear in the "Create Client" modal.
|
||||||
|
|
||||||
|
## Service Records
|
||||||
|
|
||||||
|
Domain claims include a `clients` array listing available services:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hash": "hs://...",
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"serviceName": "web",
|
||||||
|
"key": "hs://...",
|
||||||
|
"port": 8080,
|
||||||
|
"protocol": "tcp"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timestamp": 1704067200000,
|
||||||
|
"ssl": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subscribing to Services
|
||||||
|
|
||||||
|
### Via Admin Interface
|
||||||
|
|
||||||
|
1. Open the **Host** tab
|
||||||
|
2. Click **Service Subscription**
|
||||||
|
3. Browse or search for domains
|
||||||
|
4. Click **Subscribe** next to a service, or **Subscribe to All** for a domain
|
||||||
|
|
||||||
|
### Subscription Options
|
||||||
|
|
||||||
|
- **Individual**: Subscribe to specific services
|
||||||
|
- **Subscribe All**: Auto-subscribe to all current and future services from a domain
|
||||||
|
|
||||||
|
### Client Naming
|
||||||
|
|
||||||
|
Subscribed clients use the format `domain_servicename` (e.g., `example.tld_web`).
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Subscriptions are stored in `cache/subscriptions.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"domain": "example.tld",
|
||||||
|
"subscribeAll": true,
|
||||||
|
"services": [
|
||||||
|
{"serviceName": "web", "key": "hs://...", "port": 8080, "protocol": "tcp"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-Synchronization
|
||||||
|
|
||||||
|
The system automatically:
|
||||||
|
- **Subscribes** to new services when domain owners add them (if "subscribe all" is enabled)
|
||||||
|
- **Unsubscribes** when services are removed by domain owners
|
||||||
|
- **Creates clients** on startup for all subscribed services
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SUBSCRIPTIONS_FILE` | `./cache/subscriptions.json` | Subscription storage |
|
||||||
|
| `DISABLE_AUTO_SUBSCRIPTION` | `false` | Disable auto-client creation |
|
||||||
|
|
||||||
|
## Protocol Support
|
||||||
|
|
||||||
|
Services can use either:
|
||||||
|
- **TCP** (default) - Standard TCP connections
|
||||||
|
- **UDP** - For UDP-based services
|
||||||
|
|
||||||
|
Specify protocol when creating clients or subscribing.
|
||||||
|
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
# THE THEORY: A Whitepaper on Peer-to-Peer Name Resolution
|
||||||
|
|
||||||
|
**P2NS: Reclaiming the Internet's Naming Layer**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abstract
|
||||||
|
|
||||||
|
P2NS (Peer-to-Peer Name System) represents a fundamental reimagining of how domain names are resolved, owned, and served on the internet. By replacing centralized DNS infrastructure with a distributed consensus mechanism built on cryptographic identities and peer-to-peer networking, P2NS eliminates single points of failure, removes gatekeepers from domain ownership, and enables truly decentralized web hosting. This whitepaper presents the theoretical foundations, architectural decisions, and philosophical principles underlying P2NS—a system where domains are claimed through cryptographic proof, resolved through peer consensus, and served through NAT-traversing encrypted tunnels, all without requiring any central authority.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The Problem: Centralization in the Naming Layer
|
||||||
|
|
||||||
|
The Domain Name System (DNS) is often called the "phone book of the internet." What is less often acknowledged is that this phone book is controlled by a hierarchical structure of authorities—ICANN at the apex, registrars beneath, and ultimately governments with the power to seize domains or compel censorship.
|
||||||
|
|
||||||
|
### 1.1 Single Points of Failure and Control
|
||||||
|
|
||||||
|
Traditional DNS suffers from several critical weaknesses:
|
||||||
|
|
||||||
|
- **Centralized registries**: Top-level domains are controlled by designated authorities who can revoke, transfer, or censor domain names
|
||||||
|
- **Registrar dependence**: Domain "ownership" is actually a lease agreement subject to terms of service, payment requirements, and legal jurisdiction
|
||||||
|
- **Root server vulnerability**: The 13 root server clusters, while distributed, represent chokepoints that can be targeted or manipulated
|
||||||
|
- **Censorship capability**: Governments routinely order DNS-level blocking, making entire websites invisible to users within their jurisdiction
|
||||||
|
|
||||||
|
### 1.2 The NAT Problem
|
||||||
|
|
||||||
|
Beyond naming, the modern internet suffers from a connectivity crisis. Network Address Translation (NAT), originally a workaround for IPv4 address exhaustion, has become a barrier to peer-to-peer communication. Most internet users sit behind NAT devices that prevent incoming connections, forcing all communication through centralized servers that can see, log, and control traffic.
|
||||||
|
|
||||||
|
### 1.3 The Hosting Problem
|
||||||
|
|
||||||
|
Even with a domain name, serving content requires:
|
||||||
|
- Renting server infrastructure from cloud providers
|
||||||
|
- Trusting those providers not to terminate service
|
||||||
|
- Paying ongoing fees to maintain availability
|
||||||
|
- Accepting that your content lives on someone else's computer
|
||||||
|
|
||||||
|
P2NS addresses all of these problems through a unified architecture that replaces centralized naming, hosting, and connectivity with cryptographic proofs and peer consensus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Theoretical Foundations
|
||||||
|
|
||||||
|
P2NS is built on several foundational technologies and concepts that, when combined, enable fully decentralized naming and hosting.
|
||||||
|
|
||||||
|
### 2.1 Cryptographic Identity
|
||||||
|
|
||||||
|
At the core of P2NS is the principle that **identity is a keypair**. Every participant in the network possesses an Ed25519 keypair:
|
||||||
|
|
||||||
|
- The **public key** serves as a persistent, globally unique identifier
|
||||||
|
- The **private key** proves ownership and authorizes actions
|
||||||
|
- No registration, no permission, no authority required to create an identity
|
||||||
|
|
||||||
|
This cryptographic identity is deterministic and portable. A peer's identity remains constant across sessions, enabling reputation, trust, and accountability without centralized identity providers.
|
||||||
|
|
||||||
|
### 2.2 Distributed Hash Tables
|
||||||
|
|
||||||
|
P2NS uses Hyperswarm's distributed hash table (DHT) for peer discovery. The DHT enables:
|
||||||
|
|
||||||
|
- **Topic-based discovery**: Peers interested in the same topic (derived from a shared seed) can find each other
|
||||||
|
- **NAT traversal**: The DHT facilitates hole-punching to establish direct connections through NAT devices
|
||||||
|
- **No central coordination**: Peer discovery happens through the DHT's gossip protocol without any central server
|
||||||
|
|
||||||
|
### 2.3 Append-Only Data Structures
|
||||||
|
|
||||||
|
The system leverages Hypercore, an append-only log structure that provides:
|
||||||
|
|
||||||
|
- **Immutable history**: Once written, data cannot be modified without detection
|
||||||
|
- **Cryptographic verification**: Each entry is signed and linked to previous entries
|
||||||
|
- **Efficient replication**: Only new entries need to be synchronized between peers
|
||||||
|
|
||||||
|
### 2.4 Consensus Without Coordination
|
||||||
|
|
||||||
|
Traditional consensus mechanisms (Paxos, Raft, PBFT) require coordinated rounds of communication. P2NS implements **eventual consensus** through a claim-vote-resolve model that converges without explicit coordination:
|
||||||
|
|
||||||
|
- Peers independently observe claims and cast votes
|
||||||
|
- The network state converges as peers replicate data
|
||||||
|
- Resolution happens locally based on observed votes, not through coordinated agreement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The P2NS Architecture
|
||||||
|
|
||||||
|
P2NS operates as a three-layer system, each layer building on the one below.
|
||||||
|
|
||||||
|
### 3.1 Network Layer: Hyperswarm and Holesail
|
||||||
|
|
||||||
|
The network layer handles peer discovery and connectivity:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ APPLICATION LAYER │
|
||||||
|
│ (Plugins, Websites, Databases, Files) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ RESOLUTION LAYER │
|
||||||
|
│ (Claims, Votes, Consensus, Domain → Hash) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ NETWORK LAYER │
|
||||||
|
│ (Hyperswarm DHT, Holesail Tunnels, Encryption) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hyperswarm** provides the DHT for peer discovery. All P2NS nodes join a common topic (derived from a configurable seed), enabling them to find each other regardless of network topology.
|
||||||
|
|
||||||
|
**Holesail** builds encrypted tunnels on top of Hyperswarm connections. When a domain resolves to a hash, Holesail creates a tunnel to the peer serving that hash, punching through NATs as needed.
|
||||||
|
|
||||||
|
### 3.2 Resolution Layer: Autopass and Consensus
|
||||||
|
|
||||||
|
The resolution layer maintains the distributed mapping of domain names to content hashes:
|
||||||
|
|
||||||
|
**Autopass** is a distributed key-value store built on Hypercore. It stores:
|
||||||
|
- **Claims**: `claim:domain:claimant` → `{hash, timestamp, ssl, clients}`
|
||||||
|
- **Votes**: `vote:domain:claimant:voter` → `hash`
|
||||||
|
|
||||||
|
The resolution process:
|
||||||
|
1. A peer queries for domain "example.p2p"
|
||||||
|
2. The system retrieves all claims for that domain
|
||||||
|
3. Votes are tallied for each claimant
|
||||||
|
4. The claimant meeting quorum with the most votes wins
|
||||||
|
5. The winning claim's hash is returned for tunnel establishment
|
||||||
|
|
||||||
|
### 3.3 Application Layer: Plugins and Services
|
||||||
|
|
||||||
|
The application layer enables rich functionality through plugins:
|
||||||
|
|
||||||
|
- **Internal domains**: Plugins register domains (e.g., "peer.directory") that resolve to localhost
|
||||||
|
- **HyperDB**: Distributed databases with automatic P2P replication
|
||||||
|
- **Hyperdrive**: Distributed file systems for content hosting
|
||||||
|
- **Protomux channels**: Custom protocols for real-time peer communication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The Consensus Mechanism
|
||||||
|
|
||||||
|
P2NS implements a novel consensus mechanism optimized for domain name resolution. Unlike blockchain consensus (which orders all transactions globally), P2NS only needs to agree on the current owner of each domain.
|
||||||
|
|
||||||
|
### 4.1 The Claim-Vote-Resolve Model
|
||||||
|
|
||||||
|
**Claims** are assertions of domain ownership:
|
||||||
|
```
|
||||||
|
claim:mydomain:abc123def... → {
|
||||||
|
hash: "holesail-connection-hash",
|
||||||
|
timestamp: 1702800000000,
|
||||||
|
ssl: true,
|
||||||
|
clients: ["peer1", "peer2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The claimant (identified by public key) asserts they control the domain and provides a hash for connecting to their service.
|
||||||
|
|
||||||
|
**Votes** are endorsements of claims:
|
||||||
|
```
|
||||||
|
vote:mydomain:abc123def...:voter456... → "holesail-connection-hash"
|
||||||
|
```
|
||||||
|
|
||||||
|
Peers vote for the claim they believe is legitimate. By default, peers:
|
||||||
|
1. Vote for their own claims (self-vote)
|
||||||
|
2. Vote for single claimants (no competition)
|
||||||
|
3. Use tie-breaker rules when multiple claims exist
|
||||||
|
|
||||||
|
**Resolution** happens locally based on observed votes:
|
||||||
|
```
|
||||||
|
minVotes = max(MIN_VOTES, ceil(activePeers × QUORUM_THRESHOLD))
|
||||||
|
|
||||||
|
if (totalVotes >= minVotes) {
|
||||||
|
winner = claimant with most votes
|
||||||
|
// If tie, apply tie-breaker
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Quorum Mathematics
|
||||||
|
|
||||||
|
The quorum threshold (default 50%) ensures that resolution requires meaningful network participation:
|
||||||
|
|
||||||
|
- With 10 active peers and 50% threshold: 5 votes needed
|
||||||
|
- With 100 active peers: 50 votes needed
|
||||||
|
- Minimum votes (default 2) prevents single-peer takeover in small networks
|
||||||
|
|
||||||
|
This creates a balance: small networks can function with few participants, while large networks require broader consensus.
|
||||||
|
|
||||||
|
### 4.3 Tie-Breaking Strategies
|
||||||
|
|
||||||
|
When multiple claimants receive equal votes, P2NS applies deterministic tie-breakers:
|
||||||
|
|
||||||
|
1. **Timestamp** (default): Oldest claim wins (first-come-first-served)
|
||||||
|
2. **Claimant age**: Prefer claimants with longer network history
|
||||||
|
3. **Lexicographic**: Deterministic ordering by public key
|
||||||
|
|
||||||
|
These strategies ensure that resolution is deterministic—all peers observing the same data will reach the same conclusion.
|
||||||
|
|
||||||
|
### 4.4 Byzantine Fault Tolerance Properties
|
||||||
|
|
||||||
|
P2NS tolerates certain Byzantine behaviors:
|
||||||
|
|
||||||
|
- **Conflicting claims**: Resolved through voting, not prevented
|
||||||
|
- **Vote manipulation**: Mitigated by requiring quorum from diverse peers
|
||||||
|
- **Network partitions**: Each partition resolves independently; convergence occurs on reconnection
|
||||||
|
|
||||||
|
The system does not prevent Sybil attacks (one entity creating many identities), but the cost of maintaining many active peers limits practical attacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. NAT Traversal and Connectivity
|
||||||
|
|
||||||
|
P2NS solves the NAT problem through Hyperswarm's hole-punching and Holesail's tunnel abstraction.
|
||||||
|
|
||||||
|
### 5.1 Hole Punching Theory
|
||||||
|
|
||||||
|
NAT devices track outgoing connections and allow responses. Hole punching exploits this:
|
||||||
|
|
||||||
|
1. Both peers connect to the DHT (outgoing connections, allowed by NAT)
|
||||||
|
2. The DHT coordinates timing for simultaneous connection attempts
|
||||||
|
3. Both peers send packets to each other's public IP:port
|
||||||
|
4. NAT devices see these as responses to "outgoing" connections
|
||||||
|
5. A direct connection is established
|
||||||
|
|
||||||
|
This works for most NAT types (full cone, restricted cone, port-restricted cone) but may fail for symmetric NATs, where relay fallback is used.
|
||||||
|
|
||||||
|
### 5.2 Encrypted Tunnel Establishment
|
||||||
|
|
||||||
|
Once hole-punched, Holesail establishes an encrypted tunnel:
|
||||||
|
|
||||||
|
1. Domain resolves to a 64-character hex hash (the Holesail key)
|
||||||
|
2. Client creates a Holesail connection to that key
|
||||||
|
3. Holesail handles encryption, multiplexing, and reconnection
|
||||||
|
4. Traffic flows through the tunnel as if it were a local connection
|
||||||
|
|
||||||
|
### 5.3 Virtual Interface Abstraction
|
||||||
|
|
||||||
|
To make P2P domains accessible to standard browsers, P2NS creates virtual network interfaces:
|
||||||
|
|
||||||
|
1. When a domain is first accessed, a virtual IP is assigned (e.g., 192.168.3.42)
|
||||||
|
2. The IP is bound to a loopback interface alias
|
||||||
|
3. A TLS proxy listens on that IP, presenting a valid certificate
|
||||||
|
4. DNS queries for the domain return the virtual IP
|
||||||
|
5. Browser connects to the virtual IP; proxy tunnels to the P2P peer
|
||||||
|
|
||||||
|
This abstraction allows any application expecting HTTP/HTTPS to transparently access P2P content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The Plugin Ecosystem
|
||||||
|
|
||||||
|
P2NS is not just a naming system—it's a platform for decentralized applications.
|
||||||
|
|
||||||
|
### 6.1 Plugin Architecture
|
||||||
|
|
||||||
|
Plugins are self-contained applications that:
|
||||||
|
- Register internal domains (resolved to 127.0.0.1)
|
||||||
|
- Serve web interfaces through the internal HTTP server
|
||||||
|
- Access the P2NS SDK for network, DNS, and storage operations
|
||||||
|
|
||||||
|
The plugin lifecycle:
|
||||||
|
1. Plugin directory scanned at startup
|
||||||
|
2. `config.json` defines domain, routes, and capabilities
|
||||||
|
3. `index.js` (if present) runs server-side logic
|
||||||
|
4. `www/` directory serves static content
|
||||||
|
|
||||||
|
### 6.2 HyperDB: Distributed Databases
|
||||||
|
|
||||||
|
Plugins can create HyperDB databases that automatically replicate across peers:
|
||||||
|
|
||||||
|
- **Schema definition**: Collections, indexes, and resolution functions
|
||||||
|
- **Deterministic keys**: Database identity derived from plugin domain + version
|
||||||
|
- **Automatic replication**: Data syncs when peers connect on the global topic
|
||||||
|
- **Conflict resolution**: Configurable strategies for concurrent writes
|
||||||
|
|
||||||
|
This enables applications like distributed social networks, collaborative documents, or decentralized marketplaces.
|
||||||
|
|
||||||
|
### 6.3 Hyperdrive: Distributed Files
|
||||||
|
|
||||||
|
For file storage, plugins use Hyperdrive:
|
||||||
|
|
||||||
|
- **Content-addressed**: Files identified by hash, enabling deduplication
|
||||||
|
- **Sparse replication**: Only requested files are downloaded
|
||||||
|
- **Version history**: Full history of changes preserved
|
||||||
|
- **Streaming support**: Large files can be streamed without full download
|
||||||
|
|
||||||
|
### 6.4 Protomux Channels: Custom Protocols
|
||||||
|
|
||||||
|
For real-time communication, plugins register Protomux channels:
|
||||||
|
|
||||||
|
- **Protocol multiplexing**: Multiple protocols share a single connection
|
||||||
|
- **Typed messages**: JSON, string, or binary encoding
|
||||||
|
- **Bidirectional**: Both peers can send and receive
|
||||||
|
- **Auto-reconnection**: Channels re-establish when peers reconnect
|
||||||
|
|
||||||
|
This enables chat applications, real-time collaboration, gaming, and any protocol requiring low-latency peer communication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Security Model
|
||||||
|
|
||||||
|
P2NS provides security through cryptography rather than trust.
|
||||||
|
|
||||||
|
### 7.1 Cryptographic Guarantees
|
||||||
|
|
||||||
|
- **Identity**: Public keys are unforgeable; only the private key holder can sign
|
||||||
|
- **Integrity**: Hypercore's append-only structure detects tampering
|
||||||
|
- **Confidentiality**: Holesail tunnels use Noise protocol encryption
|
||||||
|
- **Authentication**: Peers verify each other's public keys during connection
|
||||||
|
|
||||||
|
### 7.2 Certificate Authority Integration
|
||||||
|
|
||||||
|
For browser compatibility, P2NS includes a local certificate authority:
|
||||||
|
|
||||||
|
1. A root CA is generated on first run
|
||||||
|
2. The root certificate can be installed in the system trust store
|
||||||
|
3. Per-domain certificates are generated on demand
|
||||||
|
4. Browsers see valid HTTPS for P2P domains
|
||||||
|
|
||||||
|
This is a pragmatic compromise: true end-to-end verification would require browser modifications, but CA-signed certificates provide immediate compatibility.
|
||||||
|
|
||||||
|
### 7.3 Peer Blocking and Trust
|
||||||
|
|
||||||
|
P2NS supports peer blocking:
|
||||||
|
|
||||||
|
- Blocked peers cannot connect or participate in consensus
|
||||||
|
- Block lists are local (not replicated)
|
||||||
|
- Future versions may support shared reputation systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Network Topology
|
||||||
|
|
||||||
|
P2NS networks can operate in various configurations.
|
||||||
|
|
||||||
|
### 8.1 Master and Non-Master Nodes
|
||||||
|
|
||||||
|
- **Master nodes**: Maintain persistent connections, higher availability, serve as reliable peers for bootstrapping
|
||||||
|
- **Non-master nodes**: Connect on-demand, may have intermittent availability
|
||||||
|
|
||||||
|
Master nodes are not privileged in consensus—they simply provide stability.
|
||||||
|
|
||||||
|
### 8.2 Peer Discovery and Gossip
|
||||||
|
|
||||||
|
Peers discover each other through:
|
||||||
|
1. DHT lookup on the global topic
|
||||||
|
2. Connection to discovered peers
|
||||||
|
3. Replication of Autopass data (claims, votes)
|
||||||
|
4. Gossip of peer information
|
||||||
|
|
||||||
|
The network is self-organizing: new peers automatically integrate and begin participating in consensus.
|
||||||
|
|
||||||
|
### 8.3 Replication Strategies
|
||||||
|
|
||||||
|
Data replication follows Hypercore's model:
|
||||||
|
- **Live replication**: Changes propagate as they occur
|
||||||
|
- **Sparse replication**: Only requested data is fetched
|
||||||
|
- **Prioritized sync**: Recent data prioritized over historical
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Philosophical Implications
|
||||||
|
|
||||||
|
P2NS embodies several philosophical principles:
|
||||||
|
|
||||||
|
### 9.1 Ownership Without Permission
|
||||||
|
|
||||||
|
In P2NS, domain ownership is established by cryptographic claim, not granted by authority. Anyone can claim any unclaimed domain. Disputes are resolved by peer consensus, not legal process.
|
||||||
|
|
||||||
|
### 9.2 Availability Through Redundancy
|
||||||
|
|
||||||
|
Content availability depends on peers willing to serve it. Popular content naturally becomes more available as more peers cache it. Unpopular content requires dedicated hosting by interested parties.
|
||||||
|
|
||||||
|
### 9.3 Censorship Resistance
|
||||||
|
|
||||||
|
No single entity can remove a domain or block access. Censorship would require:
|
||||||
|
- Controlling a majority of active peers (for consensus manipulation)
|
||||||
|
- Blocking all network paths to serving peers (for connectivity denial)
|
||||||
|
|
||||||
|
Both are difficult at scale.
|
||||||
|
|
||||||
|
### 9.4 The Cost of Decentralization
|
||||||
|
|
||||||
|
P2NS trades some conveniences for decentralization:
|
||||||
|
- No customer support for lost keys
|
||||||
|
- No legal recourse for domain disputes
|
||||||
|
- No guaranteed uptime without peer redundancy
|
||||||
|
- Higher latency than centralized DNS
|
||||||
|
|
||||||
|
These tradeoffs are acceptable for users who value sovereignty over convenience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Future Directions
|
||||||
|
|
||||||
|
P2NS is a foundation for continued development.
|
||||||
|
|
||||||
|
### 10.1 Scaling Considerations
|
||||||
|
|
||||||
|
Current challenges at scale:
|
||||||
|
- Consensus voting requires observing many claims
|
||||||
|
- Storage grows with domain count
|
||||||
|
|
||||||
|
Potential solutions:
|
||||||
|
- Sharded consensus by domain namespace
|
||||||
|
- Pruning of abandoned claims
|
||||||
|
|
||||||
|
### 10.2 Governance Models
|
||||||
|
|
||||||
|
Future versions may explore:
|
||||||
|
- Stake-weighted voting (reputation or resource commitment)
|
||||||
|
- Domain namespaces with different governance rules
|
||||||
|
- Federated networks with cross-resolution
|
||||||
|
|
||||||
|
### 10.3 Interoperability
|
||||||
|
|
||||||
|
P2NS could integrate with:
|
||||||
|
- Traditional DNS (as fallback or bridge)
|
||||||
|
- Other P2P naming systems (IPNS, ENS)
|
||||||
|
- Tor/I2P for additional anonymity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What we found
|
||||||
|
|
||||||
|
P2NS demonstrates that the internet's naming layer need not be centralized. By combining cryptographic identity, distributed consensus, NAT-traversing tunnels, and a rich plugin ecosystem, P2NS provides a complete alternative to traditional DNS and web hosting.
|
||||||
|
|
||||||
|
The system is not merely theoretical—it is implemented, functional, and available for use. Every component described in this whitepaper corresponds to working code that has been tested in real network conditions.
|
||||||
|
|
||||||
|
The internet was designed as a decentralized network of peers. Decades of commercial development have layered centralization on top of that foundation. P2NS peels back those layers, returning to the original vision: a network where participants are equals, where identity is self-sovereign, and where no single entity controls the map.
|
||||||
|
|
||||||
|
**The theory is simple: if you can prove you are you, and your peers agree you own a name, then you own that name. No permission required.**
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
# HyperDB Plugin SDK Integration
|
||||||
|
|
||||||
|
HyperDB is a database built for P2P and local indexing. This guide explains how to use HyperDB in your P2NS plugins.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
HyperDB provides a powerful database system with:
|
||||||
|
- **Schemas**: Define data structures using Hyperschema
|
||||||
|
- **Collections**: Store documents with primary keys
|
||||||
|
- **Indexes**: Create indexes for efficient queries
|
||||||
|
- **P2P Storage**: Uses Hyperbee backend for decentralized storage
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Add a `hyperdb` section to your plugin's `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my.plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"hyperdb": {
|
||||||
|
"schemas": {
|
||||||
|
"namespace": "example",
|
||||||
|
"structs": [
|
||||||
|
{
|
||||||
|
"name": "members",
|
||||||
|
"compact": true,
|
||||||
|
"fields": [
|
||||||
|
{ "name": "name", "type": "string", "required": true },
|
||||||
|
{ "name": "age", "type": "uint", "required": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"collections": [
|
||||||
|
{
|
||||||
|
"name": "members",
|
||||||
|
"schema": "@example/members",
|
||||||
|
"key": ["name"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"name": "members-by-name",
|
||||||
|
"collection": "@example/members",
|
||||||
|
"unique": true,
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"map": "mapNameToLowerCase"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"helpers": "./helpers.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The SDK will automatically:
|
||||||
|
1. Build schemas on plugin load
|
||||||
|
2. Initialize the database
|
||||||
|
3. Make it available via `sdk.db` in your plugin code
|
||||||
|
|
||||||
|
## Configuration Format
|
||||||
|
|
||||||
|
### Schemas
|
||||||
|
|
||||||
|
Define your data structures in the `schemas` section:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemas": {
|
||||||
|
"namespace": "example",
|
||||||
|
"structs": [
|
||||||
|
{
|
||||||
|
"name": "members",
|
||||||
|
"compact": true,
|
||||||
|
"fields": [
|
||||||
|
{ "name": "name", "type": "string", "required": true },
|
||||||
|
{ "name": "age", "type": "uint", "required": true },
|
||||||
|
{ "name": "email", "type": "string", "required": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schema Fields:**
|
||||||
|
- `namespace` (required): Namespace identifier for your schemas
|
||||||
|
- `structs` (required): Array of struct definitions
|
||||||
|
|
||||||
|
**Struct Fields:**
|
||||||
|
- `name` (required): Struct name
|
||||||
|
- `compact` (optional): Whether to use compact encoding (default: true)
|
||||||
|
- `fields` (required): Array of field definitions
|
||||||
|
|
||||||
|
**Field Types:**
|
||||||
|
- `string` - String value
|
||||||
|
- `uint` - Unsigned integer
|
||||||
|
- `int` - Signed integer
|
||||||
|
- `bool` - Boolean
|
||||||
|
- `bytes` - Binary data
|
||||||
|
- `@namespace/struct` - Reference to another struct
|
||||||
|
|
||||||
|
### Collections
|
||||||
|
|
||||||
|
Define collections to store documents:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"collections": [
|
||||||
|
{
|
||||||
|
"name": "members",
|
||||||
|
"schema": "@example/members",
|
||||||
|
"key": ["name"],
|
||||||
|
"derived": false,
|
||||||
|
"trigger": "membersTrigger"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Collection Fields:**
|
||||||
|
- `name` (required): Collection name
|
||||||
|
- `schema` (required): Schema identifier (e.g., `@example/members`)
|
||||||
|
- `key` (required): Array of field names for primary key (from least to most specific)
|
||||||
|
- `derived` (optional): Whether it's a derived collection (default: false)
|
||||||
|
- `trigger` (optional): Name of trigger function to run on updates
|
||||||
|
|
||||||
|
**Key Fields:**
|
||||||
|
Keys can use dot notation for nested properties:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": ["foo.id"] // Uses nested property
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Indexes
|
||||||
|
|
||||||
|
Create indexes for efficient queries:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"name": "members-by-name",
|
||||||
|
"collection": "@example/members",
|
||||||
|
"unique": true,
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"map": "mapNameToLowerCase"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Index Fields:**
|
||||||
|
- `name` (required): Index name
|
||||||
|
- `collection` (required): Collection identifier to index
|
||||||
|
- `unique` (optional): Whether index keys are unique (default: false)
|
||||||
|
- `key` (required): Either:
|
||||||
|
- Array of field names: `["name", "age"]`
|
||||||
|
- Object with `type` and `map` for custom mapping
|
||||||
|
|
||||||
|
**Index Key Mapping:**
|
||||||
|
When using a mapping function:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"map": "mapNameToLowerCase"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The mapping function must be defined in your helpers file.
|
||||||
|
|
||||||
|
### Helper Functions
|
||||||
|
|
||||||
|
Create a `helpers.js` file in your plugin directory to define custom functions:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// helpers.js
|
||||||
|
|
||||||
|
// Map function for index
|
||||||
|
exports.mapNameToLowerCase = (record, context) => {
|
||||||
|
const name = record.name.toLowerCase().trim();
|
||||||
|
return name ? [name] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collection trigger function
|
||||||
|
exports.membersTrigger = async function (db, query, record) {
|
||||||
|
// Update metadata when members change
|
||||||
|
let [digest, previous] = await Promise.all([
|
||||||
|
db.get('@example/members-digest'),
|
||||||
|
db.get('@example/members', query)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!digest) digest = { members: 0 };
|
||||||
|
|
||||||
|
const wasInserted = !!previous;
|
||||||
|
const isInserted = !!record;
|
||||||
|
|
||||||
|
if (!wasInserted && isInserted) digest.members += 1;
|
||||||
|
if (wasInserted && !isInserted) digest.members -= 1;
|
||||||
|
|
||||||
|
await db.insert('@example/members-digest', digest);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Helper Function Types:**
|
||||||
|
|
||||||
|
1. **Index Map Functions:**
|
||||||
|
```javascript
|
||||||
|
exports.mapFunctionName = (record, context) => {
|
||||||
|
// Return array of key values
|
||||||
|
return [value1, value2];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Collection Triggers:**
|
||||||
|
```javascript
|
||||||
|
exports.triggerFunctionName = async function (db, query, record) {
|
||||||
|
// db: HyperDB instance
|
||||||
|
// query: Query object used to update
|
||||||
|
// record: Document being inserted (null if deleting)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the Database in Your Plugin
|
||||||
|
|
||||||
|
### Basic Operations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const sdk = require('../../includes/plugins/sdk');
|
||||||
|
|
||||||
|
async function onInit() {
|
||||||
|
// Database is automatically initialized if hyperdb config exists
|
||||||
|
await sdk.db.ready();
|
||||||
|
|
||||||
|
// Insert a document
|
||||||
|
await sdk.db.insert('@example/members', {
|
||||||
|
name: 'Alice',
|
||||||
|
age: 30
|
||||||
|
});
|
||||||
|
await sdk.db.flush();
|
||||||
|
|
||||||
|
// Get a document
|
||||||
|
const member = await sdk.db.get('@example/members', { name: 'Alice' });
|
||||||
|
console.log(member); // { name: 'Alice', age: 30 }
|
||||||
|
|
||||||
|
// Find documents
|
||||||
|
const allMembers = await sdk.db.find('@example/members', {});
|
||||||
|
console.log(allMembers);
|
||||||
|
|
||||||
|
// Find one document
|
||||||
|
const alice = await sdk.db.findOne('@example/members', { name: 'Alice' });
|
||||||
|
|
||||||
|
// Delete a document
|
||||||
|
await sdk.db.delete('@example/members', { name: 'Alice' });
|
||||||
|
await sdk.db.flush();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying
|
||||||
|
|
||||||
|
Query syntax supports range queries:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Find documents where age >= 25
|
||||||
|
const adults = await sdk.db.find('@example/members', {
|
||||||
|
gte: { age: 25 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find documents with range
|
||||||
|
const youngAdults = await sdk.db.find('@example/members', {
|
||||||
|
gte: { age: 18 },
|
||||||
|
lt: { age: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query with options
|
||||||
|
const results = await sdk.db.find('@example/members', {}, {
|
||||||
|
limit: 10,
|
||||||
|
reverse: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Options:**
|
||||||
|
- `gt`: Greater than
|
||||||
|
- `gte`: Greater than or equal
|
||||||
|
- `lt`: Less than
|
||||||
|
- `lte`: Less than or equal
|
||||||
|
- `limit`: Maximum number of results
|
||||||
|
- `reverse`: Reverse order
|
||||||
|
|
||||||
|
### Using Indexes
|
||||||
|
|
||||||
|
Query using indexes for efficient lookups:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Query by index
|
||||||
|
const result = await sdk.db.findOne('members-by-name', {
|
||||||
|
key: 'alice' // Lowercase key from mapping function
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transactions
|
||||||
|
|
||||||
|
Use transactions for atomic operations:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Writable transaction
|
||||||
|
const tx = sdk.db.transaction();
|
||||||
|
try {
|
||||||
|
await tx.insert('@example/members', { name: 'Alice', age: 30 });
|
||||||
|
await tx.insert('@example/members', { name: 'Bob', age: 25 });
|
||||||
|
await tx.flush(); // Commits transaction
|
||||||
|
} catch (err) {
|
||||||
|
tx.close(); // Must close on error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclusive transaction (with built-in lock)
|
||||||
|
const tx = await sdk.db.exclusiveTransaction();
|
||||||
|
try {
|
||||||
|
await tx.insert('@example/members', { name: 'Alice', age: 30 });
|
||||||
|
await tx.flush();
|
||||||
|
} catch (err) {
|
||||||
|
tx.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Snapshots
|
||||||
|
|
||||||
|
Create readonly snapshots for consistent reads:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const snapshot = sdk.db.snapshot();
|
||||||
|
const members = await snapshot.find('@example/members', {});
|
||||||
|
// Snapshot reflects database state at time of creation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Watching for Changes
|
||||||
|
|
||||||
|
Watch for database updates:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
sdk.db.watch((update) => {
|
||||||
|
console.log('Database updated:', update);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unwatch
|
||||||
|
sdk.db.unwatch(callback);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changes Stream
|
||||||
|
|
||||||
|
Get stream of changes:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const changes = sdk.db.changes({
|
||||||
|
gte: 0, // Start from sequence 0
|
||||||
|
lte: 100 // Up to sequence 100
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const change of changes) {
|
||||||
|
console.log('Change:', change);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example Plugin
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// index.js
|
||||||
|
const sdk = require('../../includes/plugins/sdk');
|
||||||
|
|
||||||
|
async function handler(req, res) {
|
||||||
|
const { path, query } = sdk.router.parseRequest(req);
|
||||||
|
|
||||||
|
if (path === 'api/members' && req.method === 'GET') {
|
||||||
|
const members = await sdk.db.find('@example/members', {});
|
||||||
|
return sdk.router.json(res, { members });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === 'api/members' && req.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk.toString(); });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
await sdk.db.insert('@example/members', data);
|
||||||
|
await sdk.db.flush();
|
||||||
|
return sdk.router.json(res, { success: true });
|
||||||
|
} catch (err) {
|
||||||
|
return sdk.router.error(res, err.message, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onInit() {
|
||||||
|
sdk.log.info('my.plugin', 'Plugin initialized');
|
||||||
|
|
||||||
|
// Database is automatically initialized
|
||||||
|
await sdk.db.ready();
|
||||||
|
|
||||||
|
// Insert initial data if needed
|
||||||
|
const count = await sdk.db.find('@example/members', {});
|
||||||
|
if (count.length === 0) {
|
||||||
|
await sdk.db.insert('@example/members', {
|
||||||
|
name: 'Admin',
|
||||||
|
age: 0
|
||||||
|
});
|
||||||
|
await sdk.db.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onShutdown() {
|
||||||
|
sdk.log.info('my.plugin', 'Plugin shutting down');
|
||||||
|
// Database is automatically closed
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handler,
|
||||||
|
onInit,
|
||||||
|
onShutdown
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
After adding HyperDB configuration, your plugin directory will contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugin-sites/
|
||||||
|
my.plugin/
|
||||||
|
├── config.json # Plugin config with hyperdb section
|
||||||
|
├── index.js # Plugin handler
|
||||||
|
├── helpers.js # Optional helper functions
|
||||||
|
├── spec/
|
||||||
|
│ ├── hyperschema/ # Generated hyperschema definitions
|
||||||
|
│ └── hyperdb/ # Generated hyperdb definitions
|
||||||
|
├── db/ # Database files (managed automatically)
|
||||||
|
└── www/ # Static files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Properties
|
||||||
|
|
||||||
|
Check database state:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (sdk.db.closed) {
|
||||||
|
// Database is closed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sdk.db.writable) {
|
||||||
|
// Database is writable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sdk.db.readable) {
|
||||||
|
// Database is readable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sdk.db.updated()) {
|
||||||
|
// Database has uncommitted changes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sdk.db.updated('@example/members', { name: 'Alice' })) {
|
||||||
|
// Specific record was updated
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Always handle errors when working with the database:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
await sdk.db.insert('@example/members', data);
|
||||||
|
await sdk.db.flush();
|
||||||
|
} catch (err) {
|
||||||
|
sdk.log.error('my.plugin', `Database error: ${err.message}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always flush**: Call `await sdk.db.flush()` after inserts/updates/deletes
|
||||||
|
2. **Use transactions**: For multiple related operations
|
||||||
|
3. **Handle errors**: Wrap database operations in try-catch
|
||||||
|
4. **Use indexes**: For efficient queries on large collections
|
||||||
|
5. **Watch for changes**: Use `sdk.db.watch()` for real-time updates
|
||||||
|
6. **Close transactions**: Always call `tx.close()` if transaction fails
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Database not initialized:**
|
||||||
|
- Check that `hyperdb` section exists in `config.json`
|
||||||
|
- Verify schema configuration is correct
|
||||||
|
- Check plugin logs for schema building errors
|
||||||
|
|
||||||
|
**Helper functions not loading:**
|
||||||
|
- Ensure helpers file path is correct in config
|
||||||
|
- Check that exported function names match config
|
||||||
|
- Verify function signatures are correct
|
||||||
|
|
||||||
|
**Query errors:**
|
||||||
|
- Ensure collection/index identifiers are correct (include namespace)
|
||||||
|
- Check query syntax matches HyperDB format
|
||||||
|
- Verify primary key fields match schema
|
||||||
|
|
||||||
|
## Replication
|
||||||
|
|
||||||
|
HyperDB databases in P2NS plugins automatically replicate between peers over Hyperswarm. **All databases replicate over the same global topic** that the main P2NS application uses. This enables global plugins (chat, file sharing, etc.) that can replicate data across all peers in the P2NS network.
|
||||||
|
|
||||||
|
### How Replication Works
|
||||||
|
|
||||||
|
1. **Global Topic**: All plugin databases use the same global topic (`sha256(TOPIC_SEED || 'p2ns-dns')`) that the main P2NS app uses. This ensures all peers in the network can discover and replicate with each other.
|
||||||
|
|
||||||
|
2. **Deterministic Keypairs**: Each plugin domain generates a deterministic keypair from the domain name AND version, ensuring all peers with the same plugin version use the same database core.
|
||||||
|
|
||||||
|
3. **Version-Based Database Topics**: Database topics are generated from both the plugin domain AND version. This allows schema migrations by bumping the version in `config.json`:
|
||||||
|
- **Seed Format**: `plugin-db-{pluginDomain}-v{version}`
|
||||||
|
- **Example**: `plugin-db-global.profile-v1.1.0`
|
||||||
|
- **Important**: All peers must update to the same version to share the same database. Peers on different versions will have separate databases and cannot sync with each other.
|
||||||
|
|
||||||
|
4. **Automatic Replication**: Replication is enabled automatically when a database is created. When peers connect on the global topic, all plugin databases are automatically replicated via Corestore over Hyperswarm.
|
||||||
|
|
||||||
|
5. **Auto-Update**: HyperDB instances are created with `autoUpdate: true`, which means the database automatically updates when the underlying Hyperbee receives new data from peers.
|
||||||
|
|
||||||
|
6. **Core Preservation**: When databases are closed and reopened, cores are preserved to maintain replication state.
|
||||||
|
|
||||||
|
### Using Replication
|
||||||
|
|
||||||
|
Replication is **automatic by default** - no code needed:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Replication starts automatically when database is ready
|
||||||
|
await sdk.db.ready();
|
||||||
|
await sdk.db.insert('@example/members', { name: 'Alice' });
|
||||||
|
await sdk.db.flush();
|
||||||
|
// Data automatically replicates to peers over Hyperswarm (global topic)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replication API
|
||||||
|
|
||||||
|
The SDK provides a simple API for managing replication:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Check replication status
|
||||||
|
const status = sdk.db.replication.getStatus();
|
||||||
|
if (status && status.active) {
|
||||||
|
console.log(`Replicating with ${status.peers} peers`);
|
||||||
|
console.log(`Topic: ${status.topic}`); // Global topic (same for all databases)
|
||||||
|
console.log(`Peer IDs: ${status.peerIds.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get connected peers
|
||||||
|
const peers = sdk.db.replication.getPeers();
|
||||||
|
console.log(`Connected to ${peers.length} peers`);
|
||||||
|
|
||||||
|
// Check if replication is active
|
||||||
|
if (sdk.db.replication.isActive()) {
|
||||||
|
console.log('Replication is active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable replication (automatic by default)
|
||||||
|
await sdk.db.replication.enable();
|
||||||
|
|
||||||
|
// Disable replication (if needed)
|
||||||
|
await sdk.db.replication.disable();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replication Status
|
||||||
|
|
||||||
|
The `getStatus()` method returns:
|
||||||
|
- `active` (boolean): Whether replication is active
|
||||||
|
- `topic` (string): The global Hyperswarm topic (hex) - same for all databases
|
||||||
|
- `peers` (number): Number of connected peers
|
||||||
|
- `peerIds` (Array<string>): Array of connected peer IDs
|
||||||
|
|
||||||
|
### Global Topic Architecture
|
||||||
|
|
||||||
|
All plugin databases replicate over the same global topic:
|
||||||
|
- **Global Topic**: `sha256(TOPIC_SEED || 'p2ns-dns')` - same as the main P2NS app uses
|
||||||
|
- **Benefits**:
|
||||||
|
- All peers in the network can discover and replicate with each other
|
||||||
|
- Enables global plugins (chat, file sharing, etc.)
|
||||||
|
- Simplified replication management
|
||||||
|
- No per-database topic management needed
|
||||||
|
|
||||||
|
### Verifying Replication
|
||||||
|
|
||||||
|
Test scripts are available in the `test-scripts/` directory to verify replication works correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run replication tests
|
||||||
|
node test-scripts/test-hyperdb-replication.js
|
||||||
|
|
||||||
|
# Run all HyperDB tests
|
||||||
|
node test-scripts/test-hyperdb-replication.js
|
||||||
|
node test-scripts/test-hyperdb-management.js
|
||||||
|
node test-scripts/test-hyperdb-integration.js
|
||||||
|
```
|
||||||
|
|
||||||
|
See [test-scripts/README.md](../test-scripts/README.md) for more information about testing.
|
||||||
|
|
||||||
|
### Replication Best Practices
|
||||||
|
|
||||||
|
1. **Always flush**: Call `await sdk.db.flush()` after inserts/updates/deletes to ensure changes are persisted and replicated.
|
||||||
|
|
||||||
|
2. **Use transactions**: For multiple related operations that should be atomic across peers.
|
||||||
|
|
||||||
|
3. **Handle conflicts**: Be aware that concurrent writes from multiple peers may require conflict resolution in your application logic.
|
||||||
|
|
||||||
|
4. **Monitor updates**: Use `sdk.db.watch()` to detect when data is updated from other peers.
|
||||||
|
|
||||||
|
5. **Check status**: Use `sdk.db.replication.getStatus()` to monitor replication health.
|
||||||
|
|
||||||
|
6. **Version bumping**: Bump `version` in config.json when making incompatible schema changes (e.g., removing indexes, changing key fields). This creates a new database topic.
|
||||||
|
|
||||||
|
7. **Schema migrations**: When bumping version for schema changes:
|
||||||
|
- Update `version` in `config.json`
|
||||||
|
- Delete local database: `rm -rf plugin-sites/{domain}/db`
|
||||||
|
- Restart P2NS
|
||||||
|
- Coordinate version update with all peers
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
For more details on HyperDB, see:
|
||||||
|
- [HyperDB Documentation](https://github.com/hypercore-protocol/hyperdb)
|
||||||
|
- [Hyperschema Documentation](https://github.com/hypercore-protocol/hyperschema)
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Plugin SDK Reference](../PLUGIN_SDK.md) - Full SDK documentation
|
||||||
|
- [Plugin System Documentation](README.md) - Complete plugin system guide
|
||||||
|
- [Hyperdrive Documentation](./HYPERDRIVE.md) - Distributed file system
|
||||||
|
- [Plugin Channels Documentation](./PLUGIN_CHANNELS.md) - P2P communication
|
||||||
|
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
# Hyperdrive Plugin SDK Integration
|
||||||
|
|
||||||
|
Hyperdrive is a distributed file system built for P2P applications. This guide explains how to use Hyperdrive in your P2NS plugins for decentralized file storage and sharing.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Hyperdrive provides a powerful distributed file system with:
|
||||||
|
- **Distributed Storage**: Files are stored in a peer-to-peer network
|
||||||
|
- **Versioning**: Track changes and access historical versions
|
||||||
|
- **Replication**: Automatically sync files with peers
|
||||||
|
- **Efficient Updates**: Only changed data is transferred
|
||||||
|
- **Content-Addressable**: Files are identified by their content
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
> **Note:** The SDK is automatically available to plugins via the `require()` path relative to your plugin's location. For plugins in `plugin-sites/`, use `require('../../includes/plugins/sdk')`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const sdk = require('../../includes/plugins/sdk');
|
||||||
|
|
||||||
|
async function onInit() {
|
||||||
|
// Get or create a drive
|
||||||
|
const drive = await sdk.drives.getDrive('my-files');
|
||||||
|
|
||||||
|
// Write a file
|
||||||
|
await sdk.drives.put('my-files', '/hello.txt', Buffer.from('Hello, World!'));
|
||||||
|
|
||||||
|
// Read a file
|
||||||
|
const content = await sdk.drives.get('my-files', '/hello.txt');
|
||||||
|
console.log(content.toString()); // 'Hello, World!'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Drive Management
|
||||||
|
|
||||||
|
### Getting or Creating a Drive
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Create a new drive
|
||||||
|
const drive = await sdk.drives.getDrive('my-drive');
|
||||||
|
|
||||||
|
// Open an existing drive by key
|
||||||
|
const existingDrive = await sdk.drives.getDrive('shared-drive', 'drive-key-here');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `name` (string, required): Name of the drive
|
||||||
|
- `key` (Buffer|string, optional): Public key for an existing drive
|
||||||
|
|
||||||
|
**Returns:** `Promise<Hyperdrive>` - Hyperdrive instance
|
||||||
|
|
||||||
|
### Listing Drives
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// List all drives for this plugin
|
||||||
|
const drives = sdk.drives.listDrives();
|
||||||
|
console.log(drives); // ['my-drive', 'shared-drive']
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Drive Information
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const info = await sdk.drives.getDriveInfo('my-drive');
|
||||||
|
console.log(info);
|
||||||
|
// {
|
||||||
|
// id: 'drive-id',
|
||||||
|
// key: 'hex-encoded-key',
|
||||||
|
// discoveryKey: 'hex-encoded-discovery-key',
|
||||||
|
// contentKey: 'hex-encoded-content-key',
|
||||||
|
// writable: true,
|
||||||
|
// readable: true,
|
||||||
|
// version: 42
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Closing Drives
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Close a specific drive
|
||||||
|
await sdk.drives.closeDrive('my-drive');
|
||||||
|
|
||||||
|
// Close all drives for this plugin
|
||||||
|
await sdk.drives.closeAll();
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Operations
|
||||||
|
|
||||||
|
### Writing Files
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Write a file with buffer
|
||||||
|
await sdk.drives.put('my-drive', '/path/to/file.txt', Buffer.from('File content'));
|
||||||
|
|
||||||
|
// Write with options
|
||||||
|
await sdk.drives.put('my-drive', '/script.sh', Buffer.from('#!/bin/bash\necho hello'), {
|
||||||
|
executable: true,
|
||||||
|
metadata: { author: 'Alice' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `driveName` (string): Drive name
|
||||||
|
- `path` (string): File path in drive
|
||||||
|
- `buffer` (Buffer): File contents
|
||||||
|
- `options` (object, optional):
|
||||||
|
- `executable` (boolean): Mark file as executable
|
||||||
|
- `metadata` (object): Custom metadata
|
||||||
|
|
||||||
|
### Reading Files
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Read a file
|
||||||
|
const content = await sdk.drives.get('my-drive', '/path/to/file.txt');
|
||||||
|
console.log(content.toString());
|
||||||
|
|
||||||
|
// Read with options
|
||||||
|
const content = await sdk.drives.get('my-drive', '/path/to/file.txt', {
|
||||||
|
wait: true, // Wait for file to be available
|
||||||
|
timeout: 5000 // Timeout in milliseconds
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `driveName` (string): Drive name
|
||||||
|
- `path` (string): File path in drive
|
||||||
|
- `options` (object, optional):
|
||||||
|
- `wait` (boolean): Wait for file to be available
|
||||||
|
- `timeout` (number): Timeout in milliseconds
|
||||||
|
|
||||||
|
**Returns:** `Promise<Buffer|null>` - File contents or null if not found
|
||||||
|
|
||||||
|
### Checking File Existence
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const exists = await sdk.drives.exists('my-drive', '/path/to/file.txt');
|
||||||
|
if (exists) {
|
||||||
|
console.log('File exists');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting File Entry Information
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const entry = await sdk.drives.entry('my-drive', '/path/to/file.txt');
|
||||||
|
if (entry) {
|
||||||
|
console.log('Size:', entry.size);
|
||||||
|
console.log('Executable:', entry.executable);
|
||||||
|
console.log('Metadata:', entry.metadata);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns:** `Promise<Object|null>` - Entry object with size, executable, metadata, etc.
|
||||||
|
|
||||||
|
### Deleting Files
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await sdk.drives.del('my-drive', '/path/to/file.txt');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Operations
|
||||||
|
|
||||||
|
### Listing Files
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// List files in a directory
|
||||||
|
for await (const entry of sdk.drives.list('my-drive', '/folder')) {
|
||||||
|
console.log(entry.name, entry.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive listing
|
||||||
|
for await (const entry of sdk.drives.list('my-drive', '/folder', {
|
||||||
|
recursive: true,
|
||||||
|
ignore: ['node_modules', '.git']
|
||||||
|
})) {
|
||||||
|
console.log(entry.path);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `driveName` (string): Drive name
|
||||||
|
- `folder` (string): Folder path
|
||||||
|
- `options` (object, optional):
|
||||||
|
- `recursive` (boolean): List recursively
|
||||||
|
- `ignore` (array): Paths to ignore
|
||||||
|
- `wait` (boolean): Wait for directory to be available
|
||||||
|
|
||||||
|
**Returns:** `AsyncIterable<Object>` - Stream of file entries
|
||||||
|
|
||||||
|
### Reading Directory
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get list of subpaths
|
||||||
|
for await (const subpath of sdk.drives.readdir('my-drive', '/folder')) {
|
||||||
|
console.log(subpath); // 'file1.txt', 'subfolder', etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `driveName` (string): Drive name
|
||||||
|
- `folder` (string): Folder path
|
||||||
|
- `options` (object, optional):
|
||||||
|
- `wait` (boolean): Wait for directory to be available
|
||||||
|
|
||||||
|
**Returns:** `AsyncIterable<string>` - Stream of subpaths
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
|
||||||
|
### Reading Streams
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Create a read stream
|
||||||
|
const stream = sdk.drives.createReadStream('my-drive', '/large-file.bin');
|
||||||
|
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
console.log('Received', chunk.length, 'bytes');
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
console.log('Finished reading');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read specific range
|
||||||
|
const rangeStream = sdk.drives.createReadStream('my-drive', '/file.txt', {
|
||||||
|
start: 0,
|
||||||
|
end: 1024
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `driveName` (string): Drive name
|
||||||
|
- `path` (string): File path
|
||||||
|
- `options` (object, optional):
|
||||||
|
- `start` (number): Start byte position
|
||||||
|
- `end` (number): End byte position
|
||||||
|
- `length` (number): Number of bytes to read
|
||||||
|
- `wait` (boolean): Wait for file to be available
|
||||||
|
- `timeout` (number): Timeout in milliseconds
|
||||||
|
|
||||||
|
### Writing Streams
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Create a write stream
|
||||||
|
const stream = sdk.drives.createWriteStream('my-drive', '/output.txt');
|
||||||
|
|
||||||
|
stream.write('Hello');
|
||||||
|
stream.write(' World');
|
||||||
|
stream.end();
|
||||||
|
|
||||||
|
// With options
|
||||||
|
const execStream = sdk.drives.createWriteStream('my-drive', '/script.sh', {
|
||||||
|
executable: true,
|
||||||
|
metadata: { version: '1.0' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `driveName` (string): Drive name
|
||||||
|
- `path` (string): File path
|
||||||
|
- `options` (object, optional):
|
||||||
|
- `executable` (boolean): Mark file as executable
|
||||||
|
- `metadata` (object): Custom metadata
|
||||||
|
|
||||||
|
## Watching for Changes
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Watch for changes in a folder
|
||||||
|
for await (const [current, previous] of sdk.drives.watch('my-drive', '/folder')) {
|
||||||
|
console.log('Drive updated');
|
||||||
|
|
||||||
|
// Compare snapshots
|
||||||
|
for (const entry of current) {
|
||||||
|
if (!previous.find(e => e.path === entry.path)) {
|
||||||
|
console.log('New file:', entry.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `driveName` (string): Drive name
|
||||||
|
- `folder` (string, default: '/'): Folder path to watch
|
||||||
|
|
||||||
|
**Returns:** `AsyncIterable<Array>` - Iterator that yields `[current, previous]` snapshots
|
||||||
|
|
||||||
|
## Replication
|
||||||
|
|
||||||
|
Hyperdrive supports automatic replication over Hyperswarm for seamless file synchronization between peers. **All drives replicate over the same global topic** that the main P2NS application uses. Replication is **automatically enabled** when you create a drive.
|
||||||
|
|
||||||
|
### Automatic Replication
|
||||||
|
|
||||||
|
Replication is enabled automatically when you create a drive. All drives replicate over the same global topic, ensuring all peers in the network can discover and replicate with each other.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Replication is automatic - no setup needed!
|
||||||
|
const drive = await sdk.drives.getDrive('my-drive');
|
||||||
|
await sdk.drives.put('my-drive', '/file.txt', Buffer.from('Hello'));
|
||||||
|
// File automatically replicates to peers on the global topic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replication Management API
|
||||||
|
|
||||||
|
The SDK provides a simple API to manage and monitor replication:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Check replication status
|
||||||
|
const status = sdk.drives.replication.getStatus('my-drive');
|
||||||
|
// Returns: { active: boolean, discoveryKey: string, peers: number, peerIds: Array<string> } or null
|
||||||
|
// Note: discoveryKey is the global topic (same for all drives, kept for compatibility)
|
||||||
|
|
||||||
|
if (status && status.active) {
|
||||||
|
console.log(`Replicating with ${status.peers} peers`);
|
||||||
|
console.log(`Topic: ${status.discoveryKey}`); // Global topic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get connected peers
|
||||||
|
const peers = sdk.drives.replication.getPeers('my-drive');
|
||||||
|
peers.forEach(peerId => {
|
||||||
|
console.log(`Connected peer: ${peerId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if replication is active
|
||||||
|
if (sdk.drives.replication.isActive('my-drive')) {
|
||||||
|
console.log('Replication is active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually enable replication (if needed)
|
||||||
|
await sdk.drives.replication.enable('my-drive');
|
||||||
|
|
||||||
|
// Disable replication (if needed)
|
||||||
|
await sdk.drives.replication.disable('my-drive');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Topic Architecture
|
||||||
|
|
||||||
|
All plugin drives replicate over the same global topic:
|
||||||
|
- **Global Topic**: `sha256(TOPIC_SEED || 'p2ns-dns')` - same as the main P2NS app uses
|
||||||
|
- **Benefits**:
|
||||||
|
- All peers in the network can discover and replicate with each other
|
||||||
|
- Enables global plugins (file sharing, etc.)
|
||||||
|
- Simplified replication management
|
||||||
|
- No per-drive topic management needed
|
||||||
|
- **Note**: The `discoveryKey` field in status is kept for compatibility but all drives use the global topic
|
||||||
|
|
||||||
|
### Manual Replication (Advanced)
|
||||||
|
|
||||||
|
For advanced use cases, you can also use manual replication:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get replication stream
|
||||||
|
const replication = sdk.drives.replicate('my-drive', true); // true = is initiator
|
||||||
|
|
||||||
|
// In a plugin with peer channels, you can replicate over a channel
|
||||||
|
sdk.channels.createChannel('file-sync', {
|
||||||
|
encoding: 'binary',
|
||||||
|
onOpen: (peerId) => {
|
||||||
|
// Start replication when peer connects
|
||||||
|
const replication = sdk.drives.replicate('my-drive', true);
|
||||||
|
const channel = sdk.channels.getChannel('file-sync');
|
||||||
|
const peerChannel = channel.peerChannels.get(peerId);
|
||||||
|
if (peerChannel) {
|
||||||
|
replication.pipe(peerChannel).pipe(replication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `driveName` (string): Drive name
|
||||||
|
- `isInitiatorOrStream` (Stream|boolean): Stream to replicate over, or boolean indicating if this is the initiator
|
||||||
|
|
||||||
|
**Returns:** `ReplicationStream` - Replication stream
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
### Updating to Latest Version
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Update drive to latest version
|
||||||
|
const updated = await sdk.drives.update('my-drive', { wait: true });
|
||||||
|
if (updated) {
|
||||||
|
console.log('Drive updated to latest version');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking Out a Version
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get a read-only snapshot of a specific version
|
||||||
|
const snapshot = sdk.drives.checkout('my-drive', 10);
|
||||||
|
const content = await snapshot.get('/file.txt');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `driveName` (string): Drive name
|
||||||
|
- `version` (number): Version number
|
||||||
|
|
||||||
|
**Returns:** `Hyperdrive` - Read-only snapshot
|
||||||
|
|
||||||
|
## Batch Operations
|
||||||
|
|
||||||
|
Perform multiple operations atomically:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Create a batch
|
||||||
|
const batch = sdk.drives.batch('my-drive');
|
||||||
|
|
||||||
|
// Perform operations
|
||||||
|
await batch.put('/file1.txt', Buffer.from('Content 1'));
|
||||||
|
await batch.put('/file2.txt', Buffer.from('Content 2'));
|
||||||
|
await batch.del('/old-file.txt');
|
||||||
|
|
||||||
|
// All operations are applied atomically when batch is flushed
|
||||||
|
await batch.flush();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Downloading Files
|
||||||
|
|
||||||
|
Download files from a folder to ensure they're available locally:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Download entire folder
|
||||||
|
const download = await sdk.drives.download('my-drive', '/folder');
|
||||||
|
await download.done();
|
||||||
|
console.log('Download complete');
|
||||||
|
|
||||||
|
// Download with options
|
||||||
|
const download = await sdk.drives.download('my-drive', '/folder', {
|
||||||
|
wait: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Symlinks
|
||||||
|
|
||||||
|
Create symbolic links:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await sdk.drives.symlink('my-drive', '/link', '/target/file.txt');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Blobs
|
||||||
|
|
||||||
|
Access Hyperblobs for large binary data:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const blobs = await sdk.drives.getBlobs('my-drive');
|
||||||
|
|
||||||
|
// Store a blob
|
||||||
|
const blob = await blobs.put(Buffer.from('large binary data'));
|
||||||
|
const key = blob.key;
|
||||||
|
|
||||||
|
// Retrieve a blob
|
||||||
|
const data = await blobs.get(key);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example Plugin
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// index.js
|
||||||
|
const sdk = require('../../includes/plugins/sdk');
|
||||||
|
|
||||||
|
async function handler(req, res) {
|
||||||
|
const { path, query } = sdk.router.parseRequest(req);
|
||||||
|
|
||||||
|
if (path === 'api/files' && req.method === 'GET') {
|
||||||
|
// List files in drive
|
||||||
|
const files = [];
|
||||||
|
for await (const entry of sdk.drives.list('my-drive', '/')) {
|
||||||
|
files.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: entry.path,
|
||||||
|
size: entry.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sdk.router.json(res, { files });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === 'api/files' && req.method === 'POST') {
|
||||||
|
// Upload file
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk.toString(); });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { filePath, content } = JSON.parse(body);
|
||||||
|
await sdk.drives.put('my-drive', filePath, Buffer.from(content));
|
||||||
|
return sdk.router.json(res, { success: true });
|
||||||
|
} catch (err) {
|
||||||
|
return sdk.router.error(res, err.message, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('api/files/') && req.method === 'GET') {
|
||||||
|
// Download file
|
||||||
|
const filePath = '/' + path.split('api/files/')[1];
|
||||||
|
const content = await sdk.drives.get('my-drive', filePath);
|
||||||
|
if (content) {
|
||||||
|
return sdk.router.text(res, content.toString(), 200, 'text/plain');
|
||||||
|
}
|
||||||
|
return sdk.router.notFound(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onInit() {
|
||||||
|
sdk.log.info('my.plugin', 'Initializing plugin...');
|
||||||
|
|
||||||
|
// Get or create drive
|
||||||
|
const drive = await sdk.drives.getDrive('my-drive');
|
||||||
|
sdk.log.info('my.plugin', 'Drive ready');
|
||||||
|
|
||||||
|
// Watch for changes
|
||||||
|
(async () => {
|
||||||
|
for await (const [current, previous] of sdk.drives.watch('my-drive', '/')) {
|
||||||
|
sdk.log.info('my.plugin', 'Drive updated');
|
||||||
|
sdk.websocket.broadcast({ type: 'drive-updated' });
|
||||||
|
}
|
||||||
|
})().catch(err => {
|
||||||
|
sdk.log.error('my.plugin', `Watch error: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onShutdown() {
|
||||||
|
sdk.log.info('my.plugin', 'Shutting down plugin...');
|
||||||
|
// Close all drives
|
||||||
|
await sdk.drives.closeAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handler,
|
||||||
|
onInit,
|
||||||
|
onShutdown
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
After using Hyperdrive, your plugin directory will contain:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugin-sites/
|
||||||
|
my.plugin/
|
||||||
|
├── config.json
|
||||||
|
├── index.js
|
||||||
|
├── drives/ # Drive storage (managed automatically)
|
||||||
|
│ └── ...
|
||||||
|
└── www/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always call getDrive() first**: Most operations require the drive to be initialized
|
||||||
|
2. **Use streams for large files**: Use `createReadStream` and `createWriteStream` for files larger than a few MB
|
||||||
|
3. **Watch for changes**: Use `watch()` to react to file changes in real-time
|
||||||
|
4. **Replicate with peers**: Set up replication to sync files across the network
|
||||||
|
5. **Use batches for atomic operations**: Group related file operations in a batch
|
||||||
|
6. **Handle errors**: Wrap drive operations in try-catch blocks
|
||||||
|
7. **Close drives on shutdown**: Call `closeAll()` in `onShutdown()` to clean up resources
|
||||||
|
8. **Check file existence**: Use `exists()` before reading files that may not exist
|
||||||
|
9. **Use versioning**: Use `checkout()` to access historical versions when needed
|
||||||
|
10. **Download before reading**: Use `download()` to ensure files are available locally
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Drive not found:**
|
||||||
|
- Ensure you've called `getDrive()` before using the drive
|
||||||
|
- Check that the drive name is correct
|
||||||
|
- Verify the plugin domain is set correctly
|
||||||
|
|
||||||
|
**Files not syncing:**
|
||||||
|
- Ensure replication is set up correctly
|
||||||
|
- Check that peers are connected
|
||||||
|
- Verify the drive key is shared correctly
|
||||||
|
|
||||||
|
**Drive operations failing:**
|
||||||
|
- Check that the drive is ready: `await drive.ready()`
|
||||||
|
- Verify file paths are correct (must start with `/`)
|
||||||
|
- Ensure you have write permissions (check `writable` property)
|
||||||
|
|
||||||
|
**Performance issues:**
|
||||||
|
- Use streams for large files instead of `put()`/`get()`
|
||||||
|
- Use `download()` to pre-fetch files before reading
|
||||||
|
- Consider using batches to group operations
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Drive Management
|
||||||
|
|
||||||
|
- `sdk.drives.getDrive(name, key?)` - Get or create a drive
|
||||||
|
- `sdk.drives.listDrives()` - List all drives for this plugin
|
||||||
|
- `sdk.drives.getDriveInfo(name)` - Get drive information
|
||||||
|
- `sdk.drives.closeDrive(name)` - Close a specific drive
|
||||||
|
- `sdk.drives.closeAll()` - Close all drives
|
||||||
|
|
||||||
|
### File Operations
|
||||||
|
|
||||||
|
- `sdk.drives.put(driveName, path, buffer, options?)` - Write a file
|
||||||
|
- `sdk.drives.get(driveName, path, options?)` - Read a file
|
||||||
|
- `sdk.drives.exists(driveName, path)` - Check if file exists
|
||||||
|
- `sdk.drives.entry(driveName, path, options?)` - Get file entry info
|
||||||
|
- `sdk.drives.del(driveName, path)` - Delete a file
|
||||||
|
|
||||||
|
### Directory Operations
|
||||||
|
|
||||||
|
- `sdk.drives.list(driveName, folder, options?)` - List files in directory
|
||||||
|
- `sdk.drives.readdir(driveName, folder, options?)` - Read directory entries
|
||||||
|
|
||||||
|
### Streaming
|
||||||
|
|
||||||
|
- `sdk.drives.createReadStream(driveName, path, options?)` - Create read stream
|
||||||
|
- `sdk.drives.createWriteStream(driveName, path, options?)` - Create write stream
|
||||||
|
|
||||||
|
### Watching
|
||||||
|
|
||||||
|
- `sdk.drives.watch(driveName, folder?)` - Watch for changes
|
||||||
|
|
||||||
|
### Replication
|
||||||
|
|
||||||
|
- `sdk.drives.replicate(driveName, isInitiatorOrStream)` - Replicate drive
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
- `sdk.drives.update(driveName, options?)` - Update to latest version
|
||||||
|
- `sdk.drives.checkout(driveName, version)` - Checkout specific version
|
||||||
|
|
||||||
|
### Advanced
|
||||||
|
|
||||||
|
- `sdk.drives.batch(driveName)` - Create batch for atomic operations
|
||||||
|
- `sdk.drives.download(driveName, folder, options?)` - Download folder
|
||||||
|
- `sdk.drives.symlink(driveName, path, linkname)` - Create symlink
|
||||||
|
- `sdk.drives.getBlobs(driveName)` - Get Hyperblobs instance
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Hyperdrive Documentation](https://github.com/holepunchto/hyperdrive)
|
||||||
|
- [Plugin SDK Reference](PLUGIN_SDK.md) - Full SDK documentation
|
||||||
|
- [Plugin System Documentation](README.md) - Complete plugin system guide
|
||||||
|
- [HyperDB Documentation](HYPERDB.md) - Database operations
|
||||||
|
- [Plugin Channels Documentation](PLUGIN_CHANNELS.md) - P2P communication
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
# Plugin Channels Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Plugin Channels allow plugins to create custom protomux channels for peer-to-peer communication. This enables real-time, direct communication between peers without relying on HTTP requests.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Scoped Protocol Names**: Channels are automatically scoped to your plugin domain (e.g., `peer.chat-chat`)
|
||||||
|
- **Multiple Encoding Types**: Support for JSON, string, binary, and custom encodings
|
||||||
|
- **Auto-Reconnection**: Channels automatically reconnect when peers reconnect
|
||||||
|
- **Lifecycle Management**: Automatic cleanup on plugin shutdown
|
||||||
|
- **Message Routing**: Automatic routing of messages to your handlers
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
> **Note:** The SDK is automatically available to plugins via the `require()` path relative to your plugin's location. For plugins in `plugin-sites/`, use `require('../../includes/plugins/sdk')`.
|
||||||
|
|
||||||
|
### Creating a Channel
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const sdk = require('../../includes/plugins/sdk');
|
||||||
|
|
||||||
|
// Create a channel with JSON encoding
|
||||||
|
sdk.channels.createChannel('chat', {
|
||||||
|
encoding: 'json',
|
||||||
|
onMessage: (data, peerId, peer) => {
|
||||||
|
console.log(`Received from ${peerId}:`, data);
|
||||||
|
},
|
||||||
|
onOpen: (peerId, channel) => {
|
||||||
|
console.log(`Peer ${peerId} connected`);
|
||||||
|
},
|
||||||
|
onClose: (peerId, channel) => {
|
||||||
|
console.log(`Peer ${peerId} disconnected`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending Messages
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Send to a specific peer
|
||||||
|
sdk.channels.send('chat', peerId, {
|
||||||
|
type: 'message',
|
||||||
|
text: 'Hello!'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast to all connected peers
|
||||||
|
sdk.channels.broadcast('chat', {
|
||||||
|
type: 'announcement',
|
||||||
|
text: 'Server restarting in 5 minutes'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Channel Information
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get channel info
|
||||||
|
const channelInfo = sdk.channels.getChannel('chat');
|
||||||
|
|
||||||
|
// List all channels for this plugin
|
||||||
|
const channels = sdk.channels.listChannels();
|
||||||
|
|
||||||
|
// Get connected peers for a channel
|
||||||
|
const peers = sdk.channels.getConnectedPeers('chat');
|
||||||
|
|
||||||
|
// Check if a peer is connected
|
||||||
|
const isConnected = sdk.channels.isPeerConnected('chat', peerId);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Encoding Types
|
||||||
|
|
||||||
|
### JSON Encoding
|
||||||
|
|
||||||
|
Best for structured data:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
sdk.channels.createChannel('data', {
|
||||||
|
encoding: 'json',
|
||||||
|
onMessage: (data, peerId) => {
|
||||||
|
// data is automatically parsed as JSON
|
||||||
|
console.log(data.type, data.payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send JSON data
|
||||||
|
sdk.channels.send('data', peerId, {
|
||||||
|
type: 'update',
|
||||||
|
payload: { value: 42 }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### String Encoding
|
||||||
|
|
||||||
|
Best for text messages:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
sdk.channels.createChannel('text', {
|
||||||
|
encoding: 'string',
|
||||||
|
onMessage: (data, peerId) => {
|
||||||
|
// data is a string
|
||||||
|
console.log(`Message: ${data}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send string
|
||||||
|
sdk.channels.send('text', peerId, 'Hello, world!');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binary Encoding
|
||||||
|
|
||||||
|
Best for binary data (files, images, etc.):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
sdk.channels.createChannel('binary', {
|
||||||
|
encoding: 'binary',
|
||||||
|
onMessage: (data, peerId) => {
|
||||||
|
// data is a Buffer
|
||||||
|
console.log(`Received ${data.length} bytes`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send binary data
|
||||||
|
const buffer = Buffer.from('Hello', 'utf8');
|
||||||
|
sdk.channels.send('binary', peerId, buffer);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Encoding
|
||||||
|
|
||||||
|
Use compact-encoding directly:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const c = require('compact-encoding');
|
||||||
|
|
||||||
|
// Create custom encoding
|
||||||
|
const customEncoding = {
|
||||||
|
encode: (state, value) => {
|
||||||
|
// Custom encode logic
|
||||||
|
},
|
||||||
|
decode: (state) => {
|
||||||
|
// Custom decode logic
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sdk.channels.createChannel('custom', {
|
||||||
|
encoding: customEncoding,
|
||||||
|
onMessage: (data, peerId) => {
|
||||||
|
// Handle custom encoded data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Channel Options
|
||||||
|
|
||||||
|
### `encoding`
|
||||||
|
|
||||||
|
- **Type**: `string | Object`
|
||||||
|
- **Default**: `'string'`
|
||||||
|
- **Options**: `'json'`, `'string'`, `'binary'`, or custom encoding object
|
||||||
|
|
||||||
|
### `onMessage`
|
||||||
|
|
||||||
|
- **Type**: `Function(data, peerId, peer)`
|
||||||
|
- **Required**: No
|
||||||
|
- **Description**: Callback called when a message is received
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
onMessage: (data, peerId, peer) => {
|
||||||
|
// data: Decoded message data
|
||||||
|
// peerId: String peer ID
|
||||||
|
// peer: Peer object from protomux
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `onOpen`
|
||||||
|
|
||||||
|
- **Type**: `Function(peerId, channel)`
|
||||||
|
- **Required**: No
|
||||||
|
- **Description**: Callback called when a peer connects to the channel
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
onOpen: (peerId, channel) => {
|
||||||
|
// peerId: String peer ID
|
||||||
|
// channel: Protomux channel object
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `onClose`
|
||||||
|
|
||||||
|
- **Type**: `Function(peerId, channel)`
|
||||||
|
- **Required**: No
|
||||||
|
- **Description**: Callback called when a peer disconnects from the channel
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
onClose: (peerId, channel) => {
|
||||||
|
// peerId: String peer ID
|
||||||
|
// channel: Protomux channel object
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `autoReconnect`
|
||||||
|
|
||||||
|
- **Type**: `boolean`
|
||||||
|
- **Default**: `true`
|
||||||
|
- **Description**: Automatically recreate channels when peers reconnect
|
||||||
|
|
||||||
|
## Complete Example: Chat Plugin
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const sdk = require('../../includes/plugins/sdk');
|
||||||
|
|
||||||
|
const messages = new Map(); // Store messages per peer
|
||||||
|
|
||||||
|
async function onInit() {
|
||||||
|
// Create chat channel
|
||||||
|
sdk.channels.createChannel('chat', {
|
||||||
|
encoding: 'json',
|
||||||
|
onMessage: (data, peerId) => {
|
||||||
|
if (data.type === 'message' && data.text) {
|
||||||
|
// Store message
|
||||||
|
if (!messages.has(peerId)) {
|
||||||
|
messages.set(peerId, []);
|
||||||
|
}
|
||||||
|
messages.get(peerId).push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
peerId,
|
||||||
|
text: data.text,
|
||||||
|
direction: 'incoming'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpen: (peerId) => {
|
||||||
|
sdk.log.info('chat', `Peer ${peerId} connected`);
|
||||||
|
},
|
||||||
|
onClose: (peerId) => {
|
||||||
|
sdk.log.info('chat', `Peer ${peerId} disconnected`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(peerId, text) {
|
||||||
|
// Send message
|
||||||
|
sdk.channels.send('chat', peerId, {
|
||||||
|
type: 'message',
|
||||||
|
text: text,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store locally
|
||||||
|
if (!messages.has(peerId)) {
|
||||||
|
messages.set(peerId, []);
|
||||||
|
}
|
||||||
|
messages.get(peerId).push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
peerId,
|
||||||
|
text,
|
||||||
|
direction: 'outgoing'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(req, res) {
|
||||||
|
const { path, query } = sdk.router.parseRequest(req);
|
||||||
|
|
||||||
|
if (path === 'api/send' && req.method === 'POST') {
|
||||||
|
// Handle send message API
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk.toString(); });
|
||||||
|
req.on('end', () => {
|
||||||
|
const { peerId, message } = JSON.parse(body);
|
||||||
|
sendMessage(peerId, message);
|
||||||
|
sdk.router.json(res, { success: true });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // Let static files handle other routes
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handler, onInit };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Initialize Channels in `onInit`
|
||||||
|
|
||||||
|
Always create channels in the `onInit` hook to ensure they're ready when peers connect:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function onInit() {
|
||||||
|
sdk.channels.createChannel('my-channel', { /* ... */ });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Handle Errors Gracefully
|
||||||
|
|
||||||
|
Always wrap channel operations in try-catch:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
try {
|
||||||
|
sdk.channels.send('chat', peerId, message);
|
||||||
|
} catch (err) {
|
||||||
|
sdk.log.error('my-plugin', `Failed to send: ${err.message}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Clean Up on Shutdown
|
||||||
|
|
||||||
|
Channels are automatically cleaned up, but you can clean up local state:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function onShutdown() {
|
||||||
|
// Channels are cleaned up automatically
|
||||||
|
// Clean up local state
|
||||||
|
messages.clear();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use Appropriate Encoding
|
||||||
|
|
||||||
|
- Use `'json'` for structured data
|
||||||
|
- Use `'string'` for simple text
|
||||||
|
- Use `'binary'` for files or raw data
|
||||||
|
- Use custom encoding for specialized needs
|
||||||
|
|
||||||
|
### 5. Check Peer Connection
|
||||||
|
|
||||||
|
Before sending, check if peer is connected:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (sdk.channels.isPeerConnected('chat', peerId)) {
|
||||||
|
sdk.channels.send('chat', peerId, message);
|
||||||
|
} else {
|
||||||
|
sdk.log.warn('chat', `Peer ${peerId} not connected`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Handle Reconnections
|
||||||
|
|
||||||
|
Channels automatically reconnect, but you may want to resend state:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
onOpen: (peerId) => {
|
||||||
|
// Send current state to newly connected peer
|
||||||
|
sdk.channels.send('chat', peerId, {
|
||||||
|
type: 'state',
|
||||||
|
data: getCurrentState()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol Naming
|
||||||
|
|
||||||
|
Channels are automatically scoped to your plugin domain:
|
||||||
|
|
||||||
|
- Plugin domain: `peer.chat`
|
||||||
|
- Protocol: `chat`
|
||||||
|
- Full protocol: `peer.chat-chat`
|
||||||
|
|
||||||
|
This prevents conflicts between plugins. You don't need to worry about the full protocol name - just use your protocol name in the SDK.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
1. **Peer Must Be Connected**: You can only send to peers that are currently connected via Hyperswarm
|
||||||
|
2. **No Guaranteed Delivery**: Messages are sent over the network without delivery guarantees
|
||||||
|
3. **No Ordering Guarantees**: Messages may arrive out of order
|
||||||
|
4. **Size Limits**: Very large messages may be split or fail
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Channel Not Created
|
||||||
|
|
||||||
|
- Check that `PLUGIN_DOMAIN` is set (should be automatic)
|
||||||
|
- Verify channel creation in `onInit` hook
|
||||||
|
- Check logs for errors
|
||||||
|
|
||||||
|
### Messages Not Received
|
||||||
|
|
||||||
|
- Verify peer is connected: `sdk.channels.isPeerConnected('protocol', peerId)`
|
||||||
|
- Check encoding matches between sender and receiver
|
||||||
|
- Verify `onMessage` handler is set correctly
|
||||||
|
|
||||||
|
### Channel Not Reconnecting
|
||||||
|
|
||||||
|
- Ensure `autoReconnect: true` (default)
|
||||||
|
- Check that peer is actually reconnecting
|
||||||
|
- Verify channel wasn't manually closed
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `sdk.channels.createChannel(protocol, options)`
|
||||||
|
|
||||||
|
Create a new channel.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `protocol` (string): Protocol name
|
||||||
|
- `options` (object): Channel options
|
||||||
|
|
||||||
|
**Returns:** `boolean` - Success status
|
||||||
|
|
||||||
|
### `sdk.channels.getChannel(protocol)`
|
||||||
|
|
||||||
|
Get channel information.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `protocol` (string): Protocol name
|
||||||
|
|
||||||
|
**Returns:** `Object | null` - Channel info or null
|
||||||
|
|
||||||
|
### `sdk.channels.listChannels()`
|
||||||
|
|
||||||
|
List all channels for this plugin.
|
||||||
|
|
||||||
|
**Returns:** `Array<string>` - Array of protocol names
|
||||||
|
|
||||||
|
### `sdk.channels.closeChannel(protocol)`
|
||||||
|
|
||||||
|
Close a channel.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `protocol` (string): Protocol name
|
||||||
|
|
||||||
|
**Returns:** `boolean` - Success status
|
||||||
|
|
||||||
|
### `sdk.channels.send(protocol, peerId, data)`
|
||||||
|
|
||||||
|
Send data to a specific peer.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `protocol` (string): Protocol name
|
||||||
|
- `peerId` (string): Target peer ID
|
||||||
|
- `data` (any): Data to send
|
||||||
|
|
||||||
|
**Returns:** `boolean` - Success status
|
||||||
|
|
||||||
|
### `sdk.channels.sendAsync(protocol, peerId, data, waitTimeout)`
|
||||||
|
|
||||||
|
Send data to a specific peer (async version that waits for bidirectional channel opening).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `protocol` (string): Protocol name
|
||||||
|
- `peerId` (string): Target peer ID
|
||||||
|
- `data` (any): Data to send
|
||||||
|
- `waitTimeout` (number): Timeout to wait for channel to open in milliseconds (default: 5000)
|
||||||
|
|
||||||
|
**Returns:** `Promise<boolean>` - Success status
|
||||||
|
|
||||||
|
**Note:** This method waits for the channel to be fully opened before sending, which is useful when you need to ensure the channel is ready. The regular `send()` method returns immediately and may fail if the channel isn't open yet.
|
||||||
|
|
||||||
|
### `sdk.channels.broadcast(protocol, data)`
|
||||||
|
|
||||||
|
Broadcast data to all connected peers.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `protocol` (string): Protocol name
|
||||||
|
- `data` (any): Data to broadcast
|
||||||
|
|
||||||
|
**Returns:** `number` - Number of peers message was sent to
|
||||||
|
|
||||||
|
### `sdk.channels.getConnectedPeers(protocol)`
|
||||||
|
|
||||||
|
Get list of connected peers for a channel.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `protocol` (string): Protocol name
|
||||||
|
|
||||||
|
**Returns:** `Array<string>` - Array of peer IDs
|
||||||
|
|
||||||
|
### `sdk.channels.isPeerConnected(protocol, peerId)`
|
||||||
|
|
||||||
|
Check if a peer is connected to a channel.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `protocol` (string): Protocol name
|
||||||
|
- `peerId` (string): Peer ID
|
||||||
|
|
||||||
|
**Returns:** `boolean` - True if connected
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Plugin System Documentation](README.md) - Complete plugin system guide
|
||||||
|
- [Plugin SDK Reference](PLUGIN_SDK.md) - Full SDK documentation
|
||||||
|
- [Example Plugin](../../plugin-sites/example.plugin/index.js) - Basic plugin example
|
||||||
|
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# Domain Consensus Plugin
|
||||||
|
|
||||||
|
A comprehensive interface for visualizing and analyzing consensus within the P2NS network. This plugin provides real-time insights into domain consensus states, voting patterns, claims, and quorum information.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Domain Consensus plugin offers an advanced dashboard for monitoring and understanding how domains achieve consensus in the P2NS network. It provides detailed views of:
|
||||||
|
|
||||||
|
- Domain consensus statuses (resolved, insufficient quorum, ties, etc.)
|
||||||
|
- Vote counts and distributions
|
||||||
|
- Claims and claimants
|
||||||
|
- Quorum progress and requirements
|
||||||
|
- Real-time consensus metrics
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Overview Dashboard
|
||||||
|
|
||||||
|
- **Aggregate Statistics**: High-level metrics showing total domains, resolved domains, domains with insufficient quorum, tied domains, and active peers
|
||||||
|
- **Consensus Metrics**: Total resolutions, quorum failures, total votes cast, and average votes per domain
|
||||||
|
- **Visual Charts**:
|
||||||
|
- Pie chart showing consensus status distribution
|
||||||
|
- Bar chart displaying consensus metrics (resolutions, quorum failures, ties, validation failures)
|
||||||
|
|
||||||
|
### Domain List View
|
||||||
|
|
||||||
|
- **Comprehensive Domain Table**: Lists all domains with their consensus information
|
||||||
|
- **Search Functionality**: Search domains by name
|
||||||
|
- **Status Filtering**: Filter domains by consensus status (resolved, insufficient quorum, tie, no claims, error)
|
||||||
|
- **Sortable Columns**: Sort by domain name, status, resolved claimant, votes, quorum, or active peers
|
||||||
|
- **Quick Actions**: View detailed information for any domain with a single click
|
||||||
|
|
||||||
|
### Domain Detail View
|
||||||
|
|
||||||
|
- **Detailed Consensus Information**: Complete consensus state for a specific domain
|
||||||
|
- **Claims Display**: All claims with claimant IDs, hashes, timestamps, and vote counts
|
||||||
|
- **Votes Display**: All votes showing which voters voted for which claimants
|
||||||
|
- **Quorum Progress**: Visual progress indicator showing quorum status
|
||||||
|
- **Vote Distribution Chart**: Bar chart showing vote distribution across claimants
|
||||||
|
- **Resolved Information**: Displays resolved claimant and hash when consensus is reached
|
||||||
|
|
||||||
|
### Real-Time Updates
|
||||||
|
|
||||||
|
- **WebSocket Integration**: Real-time updates as consensus changes occur
|
||||||
|
- **Live Metrics**: Automatically updates statistics and charts without page refresh
|
||||||
|
- **Connection Status**: Visual indicator showing WebSocket connection status
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Accessing the Plugin
|
||||||
|
|
||||||
|
Once P2NS is running and the plugin is loaded, access the Domain Consensus interface at:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://domain.consensus
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
- **Overview**: Click "Overview" in the sidebar to view the main dashboard with aggregate metrics
|
||||||
|
- **Domain List**: Click "Domain List" to see all domains and their consensus statuses
|
||||||
|
- **Domain Details**: Click "View Details" on any domain in the domain list to see detailed information
|
||||||
|
|
||||||
|
### Understanding Consensus Statuses
|
||||||
|
|
||||||
|
- **Resolved** (Green): Domain has reached consensus with a resolved claimant and hash
|
||||||
|
- **Insufficient Quorum** (Yellow): Domain has claims and votes but hasn't met the minimum quorum requirement
|
||||||
|
- **Tie** (Orange): Multiple claimants have the same number of votes
|
||||||
|
- **No Claims** (Gray): Domain has no claims registered
|
||||||
|
- **Error** (Red): An error occurred while determining consensus
|
||||||
|
|
||||||
|
### Domain List Features
|
||||||
|
|
||||||
|
1. **Search**: Type in the search box to filter domains by name
|
||||||
|
2. **Filter**: Use the status dropdown to filter by consensus status
|
||||||
|
3. **Sort**: Click column headers to sort the table
|
||||||
|
4. **View Details**: Click "View Details" button to see comprehensive domain information
|
||||||
|
|
||||||
|
### Domain Detail Features
|
||||||
|
|
||||||
|
1. **Overview Cards**: Quick view of total votes, minimum required, quorum status, and active peers
|
||||||
|
2. **Quorum Progress**: Visual progress bar showing quorum percentage
|
||||||
|
3. **Resolved Information**: When resolved, shows the resolved claimant and hash with copy buttons
|
||||||
|
4. **Claims Table**: All claims with their vote counts
|
||||||
|
5. **Votes Table**: Complete list of all votes
|
||||||
|
6. **Vote Distribution Chart**: Visual representation of vote distribution
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend API Endpoints
|
||||||
|
|
||||||
|
The plugin exposes the following API endpoints:
|
||||||
|
|
||||||
|
- `GET /api/overview` - Returns aggregate consensus metrics and statistics
|
||||||
|
- `GET /api/domains` - Returns list of all domains with consensus status
|
||||||
|
- `GET /api/domain/:domain` - Returns detailed consensus state for a specific domain
|
||||||
|
- `GET /api/metrics` - Returns consensus metrics
|
||||||
|
- `GET /api/peers` - Returns active peer count and quorum information
|
||||||
|
|
||||||
|
### Frontend Components
|
||||||
|
|
||||||
|
- **app.js**: Main application logic, view management, and WebSocket handling
|
||||||
|
- **views/overview.js**: Overview dashboard with charts and statistics
|
||||||
|
- **views/domain-list.js**: Domain list table with search, filter, and sort
|
||||||
|
- **views/domain-detail.js**: Detailed domain view with claims, votes, and charts
|
||||||
|
- **api.js**: API client with caching
|
||||||
|
- **websocket.js**: WebSocket client for real-time updates
|
||||||
|
- **utils.js**: Utility functions for formatting and display
|
||||||
|
|
||||||
|
### Real-Time Updates
|
||||||
|
|
||||||
|
The plugin uses WebSocket connections to receive real-time updates:
|
||||||
|
|
||||||
|
- **Update Events**: Periodic updates every 5 seconds with current system state
|
||||||
|
- **Domain Events**: Immediate notifications when domains are added or removed
|
||||||
|
- **Chart Updates**: Charts automatically update without full page re-render
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Consensus State Structure
|
||||||
|
|
||||||
|
Each domain's consensus state includes:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
status: 'resolved' | 'insufficient_quorum' | 'tie' | 'no_claims' | 'error',
|
||||||
|
hash: string | null,
|
||||||
|
resolvedClaimant: string | null,
|
||||||
|
voteCounts: { [claimant: string]: number },
|
||||||
|
activePeers: number,
|
||||||
|
quorumMet: boolean,
|
||||||
|
minVotes: number,
|
||||||
|
totalVotes: number,
|
||||||
|
lastResolution: number | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quorum Calculation
|
||||||
|
|
||||||
|
Quorum is calculated based on:
|
||||||
|
- **Active Peers**: Number of connected peers in the network
|
||||||
|
- **Quorum Threshold**: Configurable threshold (default: 50% of active peers)
|
||||||
|
- **Minimum Votes**: Maximum of configured minimum votes and calculated threshold
|
||||||
|
|
||||||
|
Quorum is met when `totalVotes >= minVotes`.
|
||||||
|
|
||||||
|
### Data Caching
|
||||||
|
|
||||||
|
- API responses are cached for 30 seconds to reduce server load
|
||||||
|
- Cache is automatically invalidated on WebSocket updates
|
||||||
|
- Manual cache invalidation available through API calls
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- P2NS system running with DNS service initialized
|
||||||
|
- WebSocket support enabled
|
||||||
|
- Modern browser with JavaScript enabled
|
||||||
|
- Chart.js library (loaded via CDN)
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
- Chrome/Edge (latest)
|
||||||
|
- Firefox (latest)
|
||||||
|
- Safari (latest)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Charts Not Displaying
|
||||||
|
|
||||||
|
- Ensure the view container is visible before charts render
|
||||||
|
- Check browser console for JavaScript errors
|
||||||
|
- Verify Chart.js library is loaded
|
||||||
|
|
||||||
|
### WebSocket Connection Issues
|
||||||
|
|
||||||
|
- Check connection status indicator in header
|
||||||
|
- Verify P2NS is running and WebSocket server is active
|
||||||
|
- Check browser console for WebSocket errors
|
||||||
|
|
||||||
|
### Domain List Not Showing
|
||||||
|
|
||||||
|
- Verify DNS service is initialized
|
||||||
|
- Check that domains exist in the network
|
||||||
|
- Look for errors in browser console
|
||||||
|
|
||||||
|
### Real-Time Updates Not Working
|
||||||
|
|
||||||
|
- Check WebSocket connection status
|
||||||
|
- Verify WebSocket server is running
|
||||||
|
- Check browser console for errors
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
domain.consensus/
|
||||||
|
├── config.json # Plugin configuration
|
||||||
|
├── index.js # Backend handler and API endpoints
|
||||||
|
├── README.md # This file
|
||||||
|
└── www/ # Frontend files
|
||||||
|
├── index.html # Main HTML structure
|
||||||
|
├── css/
|
||||||
|
│ ├── style.css # Custom styles
|
||||||
|
│ └── tailwind.css # Tailwind CSS framework
|
||||||
|
└── js/
|
||||||
|
├── app.js # Main application logic
|
||||||
|
├── api.js # API client
|
||||||
|
├── websocket.js # WebSocket client
|
||||||
|
├── utils.js # Utility functions
|
||||||
|
└── views/
|
||||||
|
├── overview.js # Overview dashboard
|
||||||
|
├── domain-list.js # Domain list view
|
||||||
|
└── domain-detail.js # Domain detail view
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
The plugin uses Tailwind CSS. To rebuild the CSS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:css
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
1. Start P2NS
|
||||||
|
2. Navigate to `https://domain.consensus`
|
||||||
|
3. Verify all views load correctly
|
||||||
|
4. Test search, filter, and sort functionality
|
||||||
|
5. Verify WebSocket updates work
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - Same as P2NS project
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
P2NS Team
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Plugin System Documentation](README.md) - Complete plugin system guide
|
||||||
|
- [Plugin SDK Reference](../PLUGIN_SDK.md) - Full SDK API reference
|
||||||
|
- [Plugin Overview](../PLUGINS.md) - Plugin system overview
|
||||||
|
- [Consensus Mechanism](../../README.md#dns-conflict-selector) - Main P2NS documentation
|
||||||
|
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Example Plugin
|
||||||
|
|
||||||
|
A comprehensive template plugin for P2NS. Use this as a starting point when creating new plugins.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Example Plugin demonstrates all the key features available to P2NS plugins:
|
||||||
|
|
||||||
|
- Basic HTTP request handling
|
||||||
|
- Static file serving from `www/` directory
|
||||||
|
- API endpoints with query parameters
|
||||||
|
- Plugin lifecycle hooks (`onInit`, `onShutdown`)
|
||||||
|
- Admin panel settings integration
|
||||||
|
- Admin panel actions integration
|
||||||
|
- Using settings in handlers and frontend
|
||||||
|
|
||||||
|
## Features Demonstrated
|
||||||
|
|
||||||
|
### Admin Settings
|
||||||
|
|
||||||
|
The plugin registers four example settings that appear in the P2NS admin panel:
|
||||||
|
|
||||||
|
| Setting | Type | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `exampleString` | string | Greeting prefix used in API responses |
|
||||||
|
| `exampleNumber` | number | Multiplier used in calculations (1-1000) |
|
||||||
|
| `exampleBoolean` | boolean | Toggle to include timestamps in responses |
|
||||||
|
| `exampleSelect` | select | Operation mode selector (Standard/Enhanced/Advanced) |
|
||||||
|
|
||||||
|
### Admin Actions
|
||||||
|
|
||||||
|
The plugin registers three actions callable from the admin panel:
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Test Action** | Demonstrates using current settings in an action |
|
||||||
|
| **Reset Data** | Clears action history and cached data |
|
||||||
|
| **Calculate** | Performs calculation using the multiplier setting |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### `GET /api/hello`
|
||||||
|
|
||||||
|
Returns a greeting message using the configured greeting prefix.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `name` (optional): Name to greet (default: "World")
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Hello, World!",
|
||||||
|
"timestamp": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Note: `timestamp` is only included if `exampleBoolean` is true.
|
||||||
|
|
||||||
|
### `GET /api/info`
|
||||||
|
|
||||||
|
Returns plugin information and current settings.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": "example.plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A comprehensive template plugin...",
|
||||||
|
"dnsReady": true,
|
||||||
|
"connectedPeers": 5,
|
||||||
|
"settings": {
|
||||||
|
"exampleString": "Hello, World!",
|
||||||
|
"exampleNumber": 42,
|
||||||
|
"exampleBoolean": false,
|
||||||
|
"exampleSelect": "option1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/calculate`
|
||||||
|
|
||||||
|
Performs a calculation using the multiplier setting.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `value` (optional): Number to multiply (default: 0)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": 10,
|
||||||
|
"multiplier": 42,
|
||||||
|
"result": 420,
|
||||||
|
"calculation": "10 × 42 = 420"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/mode`
|
||||||
|
|
||||||
|
Returns the current operation mode and available modes.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "option1",
|
||||||
|
"description": "Standard mode - basic functionality",
|
||||||
|
"availableModes": ["option1", "option2", "option3"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/public-key`
|
||||||
|
|
||||||
|
Returns the local peer's public key.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"publicKey": "abc123...",
|
||||||
|
"hasIdentity": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/actions`
|
||||||
|
|
||||||
|
Returns the action history and last action result.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lastResult": { ... },
|
||||||
|
"history": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/settings`
|
||||||
|
|
||||||
|
Returns all current plugin settings.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"settings": { ... },
|
||||||
|
"timestamp": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
example.plugin/
|
||||||
|
├── config.json # Plugin configuration
|
||||||
|
├── index.js # Backend handler with API logic
|
||||||
|
├── README.md # Plugin documentation
|
||||||
|
├── app.log # Plugin log file (auto-generated)
|
||||||
|
└── www/ # Frontend files
|
||||||
|
├── index.html # Main HTML page
|
||||||
|
├── icon.svg # Plugin icon
|
||||||
|
├── manifest.json
|
||||||
|
└── css/
|
||||||
|
└── style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Your Own Plugin
|
||||||
|
|
||||||
|
1. **Copy this plugin directory** to `plugin-sites/your.domain/`
|
||||||
|
|
||||||
|
2. **Update `config.json`**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Your Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"domain": "your.domain",
|
||||||
|
"enabled": true,
|
||||||
|
"description": "Description of your plugin",
|
||||||
|
"author": "Your Name",
|
||||||
|
"www": "www"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Modify `index.js`**:
|
||||||
|
- Update the handler to serve your API endpoints
|
||||||
|
- Register your own settings in `onInit()`
|
||||||
|
- Register your own actions in `onInit()`
|
||||||
|
- Add cleanup logic in `onShutdown()` if needed
|
||||||
|
|
||||||
|
4. **Update `www/index.html`** with your frontend
|
||||||
|
|
||||||
|
5. **Restart P2NS** - your plugin will be automatically discovered
|
||||||
|
|
||||||
|
## Plugin SDK Usage
|
||||||
|
|
||||||
|
This plugin demonstrates key SDK features:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const sdk = require('../../includes/plugins/sdk');
|
||||||
|
|
||||||
|
// Parse requests
|
||||||
|
const { path, query, method } = sdk.router.parseRequest(req);
|
||||||
|
|
||||||
|
// Send JSON responses
|
||||||
|
return sdk.router.json(res, { data: 'value' });
|
||||||
|
|
||||||
|
// Check system state
|
||||||
|
if (sdk.utils.isDNSReady()) { ... }
|
||||||
|
const peers = sdk.state.connectedPeers;
|
||||||
|
|
||||||
|
// Register settings
|
||||||
|
sdk.admin.registerSetting('key', {
|
||||||
|
type: 'string',
|
||||||
|
label: 'Label',
|
||||||
|
description: 'Description',
|
||||||
|
default: 'default value'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get settings
|
||||||
|
const value = await sdk.admin.getSetting('key', 'default');
|
||||||
|
|
||||||
|
// Register actions
|
||||||
|
sdk.admin.registerAction('actionName', async (params) => {
|
||||||
|
return { success: true, message: 'Done!' };
|
||||||
|
}, {
|
||||||
|
label: 'Action Label',
|
||||||
|
description: 'What this action does',
|
||||||
|
icon: '🚀'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
sdk.log.info('my.plugin', 'Message');
|
||||||
|
sdk.log.error('my.plugin', 'Error message');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Plugin System Guide](../PLUGINS.md)
|
||||||
|
- [Plugin SDK Reference](../PLUGIN_SDK.md)
|
||||||
|
- [REST API Documentation](../RESTAPI.md)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# File Drop Plugin
|
||||||
|
|
||||||
|
A P2NS plugin for temporary, peer-to-peer file sharing with automatic expiration.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
File Drop enables secure, decentralized file sharing through the P2NS network. Upload a file, get a shareable link, and the file automatically expires after 24 hours. Files are stored in Hyperdrive and metadata is replicated via HyperDB across all connected peers.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **24-Hour Expiration**: Files automatically delete after 24 hours
|
||||||
|
- **Destroy on Download**: Optional one-time download links
|
||||||
|
- **Peer-to-Peer Storage**: Files stored in Hyperdrive with P2P replication
|
||||||
|
- **No Size Limits**: Limited only by available storage
|
||||||
|
- **Shareable Links**: Unique links like `/d/abc123def`
|
||||||
|
- **Modern UI**: Clean, responsive dark theme interface
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
File Drop uses two P2NS data stores:
|
||||||
|
|
||||||
|
1. **Hyperdrive** (`uploads`): Stores actual file data
|
||||||
|
- Each peer has their own writable drive
|
||||||
|
- Files replicate to other peers on demand
|
||||||
|
|
||||||
|
2. **HyperDB** (`@filedrop/files`): Stores file metadata
|
||||||
|
- File ID, name, size, MIME type
|
||||||
|
- Upload timestamp and expiration time
|
||||||
|
- Drive key for locating files across peers
|
||||||
|
- Destroy-on-download flag
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/upload` | POST | Upload a file (multipart/form-data) |
|
||||||
|
| `/api/download/:id` | GET | Download a file by ID |
|
||||||
|
| `/api/file/:id` | GET | Get file metadata |
|
||||||
|
| `/api/files` | GET | List all active files |
|
||||||
|
| `/d/:id` | GET | Human-friendly download page |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Navigate to `https://file.drop` in your browser
|
||||||
|
2. Drag and drop a file, or click "Choose File"
|
||||||
|
3. Optionally check "Destroy after download" for one-time links
|
||||||
|
4. Share the generated link
|
||||||
|
5. Recipients can download until the file expires or is destroyed
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Files uploaded on one peer are accessible to other peers via Hyperdrive replication
|
||||||
|
- The uploader's peer must be online for remote peers to download (unless cached)
|
||||||
|
- Cleanup runs hourly to remove expired files
|
||||||
|
- "Destroy on download" only works when downloaded from the uploader's peer
|
||||||
|
|
||||||
|
## Full Documentation
|
||||||
|
|
||||||
|
For complete documentation including configuration, file structure, and HyperDB schema details, see the [plugin README](../../plugin-sites/file.drop/README.md).
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Plugin System Guide](../PLUGINS.md)
|
||||||
|
- [HyperDB Integration](HYPERDB.md)
|
||||||
|
- [Hyperdrive Integration](HYPERDRIVE.md)
|
||||||
|
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
# Global Profile Plugin
|
||||||
|
|
||||||
|
**Status: ✅ Production Ready**
|
||||||
|
|
||||||
|
The Global Profile plugin provides a universal user identity system for all P2NS plugins with automatic P2P profile replication. It enables users to create, manage, and share their profiles across the entire P2NS network, with real-time updates and seamless integration with other plugins.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Global Profile is a core identity plugin that stores user profile data in a distributed HyperDB database that automatically replicates across all connected peers. Your profile is available to all P2NS plugins, enabling a unified identity experience across the network.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
|
||||||
|
- **HyperDB-Backed Storage**: All profile data is stored in HyperDB with automatic P2P replication across the network
|
||||||
|
- **Multi-Size Avatar System**: Avatars are automatically resized and stored in multiple sizes (16px, 32px, 64px, 128px, 256px, 512px) for optimal performance
|
||||||
|
- **Real-Time Updates**: Profile changes are broadcast in real-time via WebSocket to all connected clients and peers
|
||||||
|
- **Extensible Field System**: Plugins can register custom fields that extend the standard profile schema
|
||||||
|
- **Cross-Plugin Integration**: Profile data is accessible to all P2NS plugins through the unified API
|
||||||
|
- **Web UI**: Complete web interface for managing your profile with an intuitive, modern design
|
||||||
|
|
||||||
|
### Profile Fields
|
||||||
|
|
||||||
|
The plugin supports the following standard profile fields:
|
||||||
|
|
||||||
|
- `peerId` - Unique identifier for the peer (required, automatically set)
|
||||||
|
- `displayName` - User's display name
|
||||||
|
- `bio` - Biography/about text
|
||||||
|
- `email` - Email address
|
||||||
|
- `website` - Personal or professional website URL
|
||||||
|
- `xUsername` - X (Twitter) username
|
||||||
|
- `github` - GitHub username
|
||||||
|
- `discord` - Discord username
|
||||||
|
- `location` - Physical location
|
||||||
|
- `avatarHash` - Hash of the current avatar image
|
||||||
|
- `tags` - Array of user-defined tags
|
||||||
|
- `customFields` - Object for plugin-specific custom fields
|
||||||
|
- `lastUpdated` - Timestamp of last profile update
|
||||||
|
|
||||||
|
### Profile Management Features
|
||||||
|
|
||||||
|
- **Search & Filter**: Search profiles by display name or bio, filter by tags
|
||||||
|
- **Pagination**: Efficient pagination with configurable limits (1-1000 per page)
|
||||||
|
- **Sorting**: Sort profiles by last updated (newest first) or display name (alphabetical)
|
||||||
|
- **Online Status**: Real-time online/offline status for each peer
|
||||||
|
- **Statistics**: Network-wide statistics including total profiles, online counts, and recent activity
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
- **HyperDB**: All profile data and avatars are stored in HyperDB using base64-encoded data for avatars
|
||||||
|
- Profile collection (`@profile/profiles`): Stores all user profile data
|
||||||
|
- Avatar collection (`@profile/avatars`): Stores avatar images in multiple sizes
|
||||||
|
- Fields collection (`@profile/fields`): Stores custom field definitions registered by plugins
|
||||||
|
|
||||||
|
### Replication
|
||||||
|
|
||||||
|
- **Automatic P2P Replication**: All data automatically replicates across all connected peers using the global replication topic
|
||||||
|
- **Database Watching**: Real-time database change detection broadcasts updates immediately
|
||||||
|
- **Peer Connection Handling**: Profile updates are triggered when peers connect or disconnect
|
||||||
|
|
||||||
|
### Real-Time Communication
|
||||||
|
|
||||||
|
- **WebSocket Server**: Provides real-time updates to connected clients
|
||||||
|
- `profile-update`: Broadcast when a profile is created or updated
|
||||||
|
- `profile-deleted`: Broadcast when a profile is deleted
|
||||||
|
- `replication-status`: Periodic updates about replication status
|
||||||
|
- **P2P Channels**: Uses P2NS channels to broadcast profile updates across the network
|
||||||
|
|
||||||
|
## Web Interface
|
||||||
|
|
||||||
|
The plugin includes a complete web interface accessible at the plugin domain root (`/`). The interface provides:
|
||||||
|
|
||||||
|
- Profile editing form with all standard fields
|
||||||
|
- Avatar upload with preview
|
||||||
|
- Tag management with inline editing
|
||||||
|
- Real-time profile listing with search
|
||||||
|
- Online/offline status indicators
|
||||||
|
- Connection status monitoring
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
The plugin provides a comprehensive REST API. For interactive documentation with examples and curl commands, visit `/api/docs` when the plugin is enabled.
|
||||||
|
|
||||||
|
### Profile Management
|
||||||
|
|
||||||
|
#### Get Your Profile
|
||||||
|
```
|
||||||
|
GET /api/profile
|
||||||
|
```
|
||||||
|
Returns your local profile. Returns a default empty profile if none exists.
|
||||||
|
|
||||||
|
#### Update Your Profile (Full)
|
||||||
|
```
|
||||||
|
PUT /api/profile
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"displayName": "John Doe",
|
||||||
|
"bio": "Software developer",
|
||||||
|
"website": "https://example.com",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"xUsername": "@johndoe",
|
||||||
|
"github": "johndoe",
|
||||||
|
"discord": "johndoe#1234",
|
||||||
|
"location": "San Francisco, CA",
|
||||||
|
"tags": ["developer", "opensource"],
|
||||||
|
"customFields": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Performs a full update of your profile. All fields must be provided.
|
||||||
|
|
||||||
|
#### Partially Update Your Profile
|
||||||
|
```
|
||||||
|
PATCH /api/profile
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"displayName": "John Doe",
|
||||||
|
"bio": "Updated bio"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Updates only the provided fields. Fields not included remain unchanged.
|
||||||
|
|
||||||
|
#### Get Another Peer's Profile
|
||||||
|
```
|
||||||
|
GET /api/profile/:peerId
|
||||||
|
```
|
||||||
|
Returns the profile for the specified peer ID. Returns 404 if not found.
|
||||||
|
|
||||||
|
#### Delete Your Profile
|
||||||
|
```
|
||||||
|
DELETE /api/profile
|
||||||
|
```
|
||||||
|
Deletes your profile and all associated avatars.
|
||||||
|
|
||||||
|
### Profile Listing
|
||||||
|
|
||||||
|
#### List All Profiles
|
||||||
|
```
|
||||||
|
GET /api/profiles?search=query&tag=tagName&limit=100&offset=0&sort=lastUpdated
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `search` (optional): Search term to filter by displayName or bio (case-insensitive)
|
||||||
|
- `tag` (optional): Filter profiles that have this tag
|
||||||
|
- `limit` (optional): Maximum number of results (1-1000, default: 100)
|
||||||
|
- `offset` (optional): Number of results to skip for pagination (default: 0)
|
||||||
|
- `sort` (optional): Sort field - `lastUpdated` (default, newest first) or `displayName` (alphabetical)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"peerId": "...",
|
||||||
|
"displayName": "John Doe",
|
||||||
|
"bio": "...",
|
||||||
|
"online": true,
|
||||||
|
"tags": ["developer"],
|
||||||
|
"customFields": {},
|
||||||
|
"lastUpdated": 1234567890
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"total": 150,
|
||||||
|
"limit": 100,
|
||||||
|
"offset": 0,
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avatar Management
|
||||||
|
|
||||||
|
#### Upload Avatar
|
||||||
|
```
|
||||||
|
POST /api/profile/avatar
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
avatar: <image file>
|
||||||
|
```
|
||||||
|
Uploads an avatar image. The image is automatically resized to all supported sizes (16px, 32px, 64px, 128px, 256px, 512px) and stored in HyperDB. Requires the `sharp` library for image processing.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"avatarHash": "md5_hash_of_image"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Avatar (Default Size)
|
||||||
|
```
|
||||||
|
GET /api/profile/avatar/:peerId
|
||||||
|
```
|
||||||
|
Redirects to the 64px avatar endpoint.
|
||||||
|
|
||||||
|
#### Get Avatar (Specific Size)
|
||||||
|
```
|
||||||
|
GET /api/profile/avatar/:peerId/:size
|
||||||
|
```
|
||||||
|
Returns the avatar image at the specified size. Valid sizes: `16`, `32`, `64`, `128`, `256`, `512`.
|
||||||
|
|
||||||
|
If no avatar exists, returns a default SVG placeholder with the first character of the peer ID.
|
||||||
|
|
||||||
|
#### Delete Avatar
|
||||||
|
```
|
||||||
|
DELETE /api/profile/avatar
|
||||||
|
```
|
||||||
|
Deletes all avatar sizes for your profile.
|
||||||
|
|
||||||
|
### Custom Fields
|
||||||
|
|
||||||
|
#### Get All Registered Fields
|
||||||
|
```
|
||||||
|
GET /api/fields
|
||||||
|
```
|
||||||
|
Returns all custom fields registered by plugins.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldKey": "favoriteColor",
|
||||||
|
"registeredBy": "example.plugin",
|
||||||
|
"label": "Favorite Color",
|
||||||
|
"type": "string",
|
||||||
|
"description": "User's favorite color",
|
||||||
|
"defaultValue": "blue"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Register a Custom Field
|
||||||
|
```
|
||||||
|
POST /api/fields
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"fieldKey": "favoriteColor",
|
||||||
|
"label": "Favorite Color",
|
||||||
|
"type": "string",
|
||||||
|
"description": "User's favorite color",
|
||||||
|
"defaultValue": "blue"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Fields:**
|
||||||
|
- `fieldKey`: Unique identifier for the field (string)
|
||||||
|
- `label`: Human-readable label (string)
|
||||||
|
- `type`: Field type - one of: `string`, `number`, `boolean`, `array`, `object`
|
||||||
|
|
||||||
|
**Optional Fields:**
|
||||||
|
- `description`: Field description (string)
|
||||||
|
- `defaultValue`: Default value (any JSON-serializable value)
|
||||||
|
|
||||||
|
**Note:** Custom fields are stored in the `customFields` object in the profile. Plugins should register their fields on initialization to ensure proper validation and documentation.
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
#### Get Profile Statistics
|
||||||
|
```
|
||||||
|
GET /api/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 150,
|
||||||
|
"online": 45,
|
||||||
|
"offline": 105,
|
||||||
|
"recentActivity": [
|
||||||
|
{
|
||||||
|
"peerId": "...",
|
||||||
|
"displayName": "John Doe",
|
||||||
|
"hoursAgo": 2.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- Total number of profiles in the network
|
||||||
|
- Count of online vs offline peers
|
||||||
|
- Recent activity (profiles updated in the last 24 hours, limited to top 10)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
#### Interactive API Documentation
|
||||||
|
```
|
||||||
|
GET /api/docs
|
||||||
|
```
|
||||||
|
Returns an interactive HTML page with complete API documentation, including examples and curl commands for each endpoint.
|
||||||
|
|
||||||
|
## WebSocket API
|
||||||
|
|
||||||
|
The plugin provides WebSocket endpoints for real-time updates. Connect to the WebSocket server to receive:
|
||||||
|
|
||||||
|
### Message Types
|
||||||
|
|
||||||
|
#### `profile-update`
|
||||||
|
Broadcast when a profile is created or updated.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "profile-update",
|
||||||
|
"profile": {
|
||||||
|
"peerId": "...",
|
||||||
|
"displayName": "John Doe",
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `profile-deleted`
|
||||||
|
Broadcast when a profile is deleted.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "profile-deleted",
|
||||||
|
"peerId": "...",
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `replication-status`
|
||||||
|
Periodic updates about replication status (every 2 seconds when status changes).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "replication-status",
|
||||||
|
"data": {
|
||||||
|
"hyperdb": {
|
||||||
|
"active": true,
|
||||||
|
"peers": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Messages
|
||||||
|
|
||||||
|
#### `request-profiles`
|
||||||
|
Request all profiles (for backward compatibility; use REST API `/api/profiles` instead).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "request-profiles"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `request-replication-status`
|
||||||
|
Request current replication status.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "request-replication-status"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The plugin is enabled by default. Configuration is managed through `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Global Profile",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"domain": "global.profile",
|
||||||
|
"enabled": true,
|
||||||
|
"description": "Universal user identity system for all P2NS plugins with P2P profile replication"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enabling/Disabling
|
||||||
|
|
||||||
|
The plugin can be enabled or disabled through:
|
||||||
|
|
||||||
|
1. **Admin Panel**: Navigate to the Plugins tab and toggle the Global Profile plugin status
|
||||||
|
2. **Manual Configuration**: Edit `plugin-sites/global.profile/config.json` and change `"enabled"` to `true` or `false`, then restart P2NS
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Required
|
||||||
|
- P2NS core with HyperDB support
|
||||||
|
- Sharp library for avatar image processing: `npm install sharp`
|
||||||
|
|
||||||
|
**Note:** If Sharp is not installed, avatar upload will fail with an error message. The plugin will function normally for all other features.
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The plugin uses HyperDB with the following schema:
|
||||||
|
|
||||||
|
### Profile Schema
|
||||||
|
- `peerId` (string, required): Unique peer identifier (primary key)
|
||||||
|
- `displayName` (string): Display name
|
||||||
|
- `bio` (string): Biography
|
||||||
|
- `website` (string): Website URL
|
||||||
|
- `email` (string): Email address
|
||||||
|
- `xUsername` (string): X/Twitter username
|
||||||
|
- `github` (string): GitHub username
|
||||||
|
- `discord` (string): Discord username
|
||||||
|
- `location` (string): Location
|
||||||
|
- `avatarHash` (string): Avatar hash
|
||||||
|
- `customFields` (string): JSON-encoded object for custom fields
|
||||||
|
- `tags` (string): JSON-encoded array of tags
|
||||||
|
- `lastUpdated` (uint): Timestamp of last update
|
||||||
|
|
||||||
|
### Avatar Schema
|
||||||
|
- `peerId` (string, required): Peer identifier (part of composite key)
|
||||||
|
- `size` (string, required): Size identifier - one of: `"16"`, `"32"`, `"64"`, `"128"`, `"256"`, `"512"`, `"original"` (part of composite key)
|
||||||
|
- `data` (string, required): Base64-encoded image data
|
||||||
|
|
||||||
|
### Field Definition Schema
|
||||||
|
- `fieldKey` (string, required): Unique field key (primary key)
|
||||||
|
- `registeredBy` (string, required): Plugin domain that registered the field
|
||||||
|
- `label` (string): Human-readable label
|
||||||
|
- `type` (string): Field type
|
||||||
|
- `description` (string): Field description
|
||||||
|
- `defaultValue` (string): JSON-encoded default value
|
||||||
|
|
||||||
|
### Indexes
|
||||||
|
|
||||||
|
- `profiles-by-displayName`: Case-insensitive search on displayName
|
||||||
|
|
||||||
|
## Plugin Integration
|
||||||
|
|
||||||
|
Other P2NS plugins can integrate with Global Profile in several ways:
|
||||||
|
|
||||||
|
### Reading Profile Data
|
||||||
|
|
||||||
|
Plugins can fetch profile data using the REST API:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Get a specific peer's profile
|
||||||
|
const profile = await fetch(`https://global.profile/api/profile/${peerId}`);
|
||||||
|
const data = await profile.json();
|
||||||
|
|
||||||
|
// Access standard fields
|
||||||
|
console.log(data.displayName, data.bio, data.avatarHash);
|
||||||
|
|
||||||
|
// Access custom fields
|
||||||
|
console.log(data.customFields.myCustomField);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Custom Fields
|
||||||
|
|
||||||
|
Plugins can extend the profile schema by registering custom fields:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Register a custom field on plugin initialization
|
||||||
|
await fetch('https://global.profile/api/fields', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fieldKey: 'myCustomField',
|
||||||
|
label: 'My Custom Field',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Description of my custom field',
|
||||||
|
defaultValue: 'default'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Avatars
|
||||||
|
|
||||||
|
Avatars can be accessed via URL:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Default size (64px) -->
|
||||||
|
<img src="https://global.profile/api/profile/avatar/PEER_ID">
|
||||||
|
|
||||||
|
<!-- Specific size -->
|
||||||
|
<img src="https://global.profile/api/profile/avatar/PEER_ID/128">
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Integration
|
||||||
|
|
||||||
|
Plugins can connect to the WebSocket server to receive real-time profile updates:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const ws = new WebSocket('wss://global.profile/ws');
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.type === 'profile-update') {
|
||||||
|
// Handle profile update
|
||||||
|
console.log('Profile updated:', message.profile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- **Avatar Sizes**: Avatars are pre-generated in multiple sizes to avoid on-the-fly resizing
|
||||||
|
- **Database Indexes**: Indexes on displayName and tags enable efficient searching
|
||||||
|
- **Pagination**: Profile listings use pagination to handle large networks efficiently
|
||||||
|
- **Caching**: Avatar endpoints include cache headers for optimal performance
|
||||||
|
- **Base64 Storage**: Avatars are stored as base64 in HyperDB for seamless replication
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Avatar Upload Fails
|
||||||
|
|
||||||
|
- Ensure the `sharp` library is installed: `npm install sharp`
|
||||||
|
- Check that the uploaded file is a valid image format
|
||||||
|
- Verify file size is reasonable (large files may take time to process)
|
||||||
|
|
||||||
|
### Profile Updates Not Replicating
|
||||||
|
|
||||||
|
- Verify that database replication is active (check `/api/stats` or WebSocket replication-status)
|
||||||
|
- Ensure peers are connected and replication is enabled in P2NS
|
||||||
|
- Check logs for replication errors
|
||||||
|
|
||||||
|
### Database Not Ready Errors
|
||||||
|
|
||||||
|
- The plugin waits up to 30 seconds for database initialization on startup
|
||||||
|
- If errors persist, restart P2NS to re-initialize the database
|
||||||
|
- Check that HyperDB is properly configured in P2NS
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Peer Directory Plugin
|
||||||
|
|
||||||
|
A P2NS plugin that provides a web interface for browsing and searching all domains in the P2NS network.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Peer Directory plugin serves as a central directory for discovering domains within the P2NS peer-to-peer network. It displays all claimed domains, categorizes them by type, and provides search and pagination functionality.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Domain Listing**: View all domains in the P2NS network
|
||||||
|
- **Domain Categorization**: Domains are categorized as:
|
||||||
|
- `remote` - Domains owned by other peers in the network
|
||||||
|
- `local` - Domains you own in the P2P network
|
||||||
|
- `internal` - Internal P2NS system domains (e.g., `p2ns.admin`)
|
||||||
|
- `plugin` - Plugin domains from `plugin-sites/`
|
||||||
|
- **Sorting Options**: Sort domains by type, name (A-Z or Z-A), or hash
|
||||||
|
- **Pagination**: Navigate through large domain lists with page controls
|
||||||
|
- **Real-Time Data**: Domain list is fetched fresh from the P2NS network on each request
|
||||||
|
- **Responsive UI**: Clean, modern interface with Tailwind CSS
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
Navigate to `https://peer.directory` in your browser (requires P2NS to be running and the root CA to be trusted).
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### `GET /domains`
|
||||||
|
|
||||||
|
Returns a paginated list of all domains in the P2NS network.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `page` (optional): Page number (1-indexed, default: 1)
|
||||||
|
- `limit` (optional): Domains per page (1-10, default: 10)
|
||||||
|
- `sort` (optional): Sort order - one of:
|
||||||
|
- `type` (default) - Sort by domain type
|
||||||
|
- `type-desc` - Sort by domain type (reversed)
|
||||||
|
- `name` - Sort alphabetically (A-Z)
|
||||||
|
- `name-desc` - Sort alphabetically (Z-A)
|
||||||
|
- `hash` - Sort by hash
|
||||||
|
- `hash-desc` - Sort by hash (reversed)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domains": [
|
||||||
|
{
|
||||||
|
"domain": "example.tld",
|
||||||
|
"type": "remote",
|
||||||
|
"hash": "hs://s00084bf...",
|
||||||
|
"isLocal": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"domain": "peer.directory",
|
||||||
|
"type": "internal",
|
||||||
|
"hash": "internal",
|
||||||
|
"isLocal": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 15,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 10,
|
||||||
|
"totalPages": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Panel Integration
|
||||||
|
|
||||||
|
The plugin registers the following in the P2NS admin panel:
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
- **Refresh Domains**: Manually trigger a domain list refresh
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- **Default Sort Order**: Choose the default sorting method for the domain list
|
||||||
|
- **Items Per Page**: Set the default number of domains shown per page
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
peer.directory/
|
||||||
|
├── config.json # Plugin configuration
|
||||||
|
├── index.js # Backend handler and API logic
|
||||||
|
├── README.md # Plugin documentation
|
||||||
|
└── www/ # Frontend files
|
||||||
|
├── index.html # Main HTML page
|
||||||
|
├── icon.svg # Plugin icon
|
||||||
|
├── manifest.json
|
||||||
|
└── css/
|
||||||
|
├── style.css
|
||||||
|
└── tailwind.css
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Domain Discovery**: The plugin fetches all DNS entries from the P2NS network
|
||||||
|
2. **Categorization**: Each domain is categorized based on:
|
||||||
|
- Whether it's a plugin domain (in `plugin-sites/`)
|
||||||
|
- Whether it's an internal domain (like `p2ns.admin`)
|
||||||
|
- Whether the local peer is the resolved claimant (owner)
|
||||||
|
3. **Sorting**: Domains are sorted according to the requested sort option
|
||||||
|
4. **Pagination**: Results are paginated for efficient browsing
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The plugin is configured via `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "peer.directory",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"domain": "peer.directory",
|
||||||
|
"enabled": true,
|
||||||
|
"description": "P2NS peer directory browser - Browse and search P2NS domains",
|
||||||
|
"www": "www"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- P2NS Plugin SDK
|
||||||
|
- No external dependencies required
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Plugin System Guide](../PLUGINS.md)
|
||||||
|
- [Plugin SDK Reference](../PLUGIN_SDK.md)
|
||||||
|
- [REST API Documentation](../RESTAPI.md)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
# Peer Visualize
|
||||||
|
|
||||||
|
Interactive real-time visualization of P2NS peer connections and system architecture.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Peer Visualize is a P2NS plugin that provides a comprehensive, interactive dashboard for monitoring and visualizing your P2NS network. It offers real-time updates via WebSocket, multiple visualization modes, and detailed insights into peer connections, domains, and system metrics.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Multiple Visualization Views
|
||||||
|
|
||||||
|
- **System Overview**: High-level dashboard with key metrics, charts, and statistics
|
||||||
|
- **Network Graph**: Interactive force-directed or hierarchical graph of peer connections
|
||||||
|
- **Peer Details**: Detailed list and information about all peers in the network
|
||||||
|
- **Domain Map**: Visual representation of domains and their relationships to peers
|
||||||
|
|
||||||
|
### Real-Time Updates
|
||||||
|
|
||||||
|
- WebSocket-based real-time data streaming
|
||||||
|
- Automatic updates every 5 seconds
|
||||||
|
- Live connection status indicators
|
||||||
|
- Instant reflection of network changes
|
||||||
|
|
||||||
|
### Interactive Visualizations
|
||||||
|
|
||||||
|
- **Force-Directed Graph**: Physics-based layout showing natural clustering of connected peers
|
||||||
|
- **Hierarchical Graph**: Tree-based layout for structured network analysis
|
||||||
|
- **Zoom & Pan**: Full D3.js zoom and pan support for exploring large networks
|
||||||
|
- **Node Interaction**: Click nodes to view detailed information
|
||||||
|
|
||||||
|
### Advanced Filtering
|
||||||
|
|
||||||
|
- Filter by connection status (connected/disconnected peers)
|
||||||
|
- Toggle domain visibility
|
||||||
|
- Search peers and domains by name or ID
|
||||||
|
- Show/hide offline peers (hierarchical layout)
|
||||||
|
|
||||||
|
### Metrics & Analytics
|
||||||
|
|
||||||
|
- Total peers seen
|
||||||
|
- Currently connected peers
|
||||||
|
- Total domains registered
|
||||||
|
- Active connections count
|
||||||
|
- System uptime tracking
|
||||||
|
- Peer connection timeline charts
|
||||||
|
- Connection duration distribution
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Peer Visualize is automatically available as an internal domain when P2NS is running. No additional installation is required.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
Navigate to `https://peer.visualize` in your browser (or the configured internal domain URL).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### System Overview
|
||||||
|
|
||||||
|
The default view provides a high-level dashboard with:
|
||||||
|
|
||||||
|
- **Metric Cards**: Key statistics at a glance
|
||||||
|
- Total Peers Seen
|
||||||
|
- Connected Peers
|
||||||
|
- Total Domains
|
||||||
|
- System Uptime
|
||||||
|
- Active Connections
|
||||||
|
|
||||||
|
- **Charts**:
|
||||||
|
- **Peer Connection Timeline**: Line chart showing peer connections over time
|
||||||
|
- **Connection Duration Distribution**: Histogram of connection durations
|
||||||
|
|
||||||
|
### Network Graph
|
||||||
|
|
||||||
|
Visualize the network topology with interactive graphs:
|
||||||
|
|
||||||
|
1. **Select Layout**:
|
||||||
|
- **Force-Directed**: Physics simulation showing natural clustering
|
||||||
|
- **Hierarchical**: Tree structure with local node at root
|
||||||
|
|
||||||
|
2. **Interact with Nodes**:
|
||||||
|
- Click on peer nodes to view detailed information
|
||||||
|
- Click on domain nodes to see domain details
|
||||||
|
- Drag nodes to reposition (force-directed layout)
|
||||||
|
- Zoom and pan to explore large networks
|
||||||
|
|
||||||
|
3. **Filter Options**:
|
||||||
|
- Toggle connected peers visibility
|
||||||
|
- Toggle domains visibility
|
||||||
|
- Search for specific peers or domains
|
||||||
|
- Show offline peers (hierarchical layout only)
|
||||||
|
|
||||||
|
### Peer Details
|
||||||
|
|
||||||
|
View comprehensive information about all peers:
|
||||||
|
|
||||||
|
- Peer ID (full and truncated)
|
||||||
|
- Connection status
|
||||||
|
- Uptime duration
|
||||||
|
- Local node indicator
|
||||||
|
- Click any peer to open detailed side panel with:
|
||||||
|
- Full peer ID
|
||||||
|
- Connection metrics
|
||||||
|
- Connection history
|
||||||
|
- Last seen timestamp
|
||||||
|
|
||||||
|
### Domain Map
|
||||||
|
|
||||||
|
Visualize domain ownership and relationships:
|
||||||
|
|
||||||
|
- See which peers own which domains
|
||||||
|
- View domain-to-peer connections
|
||||||
|
- Filter by domain or peer
|
||||||
|
- Interactive exploration of domain topology
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The plugin exposes several REST API endpoints:
|
||||||
|
|
||||||
|
### `GET /api/system`
|
||||||
|
Get complete system state including peers, domains, and metrics.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"peers": [...],
|
||||||
|
"domains": [...],
|
||||||
|
"localPeerId": "...",
|
||||||
|
"peerChannels": [...],
|
||||||
|
"metrics": {...},
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/peers`
|
||||||
|
Get detailed information about all peers.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"peers": [
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"connected": true,
|
||||||
|
"isLocal": false,
|
||||||
|
"connectTime": 1234567890,
|
||||||
|
"uptime": 3600000,
|
||||||
|
"metrics": {...},
|
||||||
|
"history": [...]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/domains`
|
||||||
|
Get all registered domains.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domains": [
|
||||||
|
{
|
||||||
|
"domain": "example.tld",
|
||||||
|
"hash": "hs://...",
|
||||||
|
"consensus": {...}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/topology`
|
||||||
|
Get network topology data for graph rendering.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"type": "peer",
|
||||||
|
"label": "...",
|
||||||
|
"isLocal": true,
|
||||||
|
"connected": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"source": "...",
|
||||||
|
"target": "...",
|
||||||
|
"type": "connection",
|
||||||
|
"bidirectional": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/metrics`
|
||||||
|
Get system-wide metrics.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metrics": {
|
||||||
|
"peers": {...},
|
||||||
|
"domains": {...},
|
||||||
|
"connections": {...}
|
||||||
|
},
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/peer/:peerId`
|
||||||
|
Get detailed information for a specific peer.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "...",
|
||||||
|
"connected": true,
|
||||||
|
"connectTime": 1234567890,
|
||||||
|
"uptime": 3600000,
|
||||||
|
"metrics": {...},
|
||||||
|
"history": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/domain/:domain`
|
||||||
|
Get detailed information for a specific domain.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domain": "example.tld",
|
||||||
|
"hash": "hs://...",
|
||||||
|
"consensus": {...},
|
||||||
|
"clients": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket API
|
||||||
|
|
||||||
|
The plugin provides real-time updates via WebSocket at `/ws`.
|
||||||
|
|
||||||
|
### Connection
|
||||||
|
|
||||||
|
Connect to `wss://peer.visualize/ws` (or `ws://` for non-HTTPS).
|
||||||
|
|
||||||
|
### Message Types
|
||||||
|
|
||||||
|
#### Client → Server
|
||||||
|
|
||||||
|
**Request System State:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "request-system"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Topology:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "request-topology"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Metrics:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "request-metrics"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Server → Client
|
||||||
|
|
||||||
|
**Initial State:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "init",
|
||||||
|
"data": { /* system state */ },
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**System Update:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "system-update",
|
||||||
|
"data": { /* system state */ },
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Topology Update:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "topology-update",
|
||||||
|
"data": { /* topology data */ },
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Metrics Update:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "metrics-update",
|
||||||
|
"data": { /* metrics data */ },
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend (`index.js`)
|
||||||
|
|
||||||
|
The plugin handler provides:
|
||||||
|
|
||||||
|
- **HTTP Request Handler**: Serves static files and handles API endpoints
|
||||||
|
- **WebSocket Server**: Real-time data streaming to connected clients
|
||||||
|
- **Periodic Updates**: Automatic system state polling every 5 seconds
|
||||||
|
- **Data Aggregation**: Combines data from multiple P2NS SDK sources
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
**Main Application (`app.js`)**:
|
||||||
|
- View management and navigation
|
||||||
|
- WebSocket client integration
|
||||||
|
- Data flow coordination
|
||||||
|
|
||||||
|
**Visualization Components**:
|
||||||
|
- `force-graph.js`: Force-directed graph using D3.js
|
||||||
|
- `hierarchical.js`: Hierarchical tree layout
|
||||||
|
- `domain-map.js`: Domain visualization
|
||||||
|
- `dashboard.js`: Charts and metrics display
|
||||||
|
- `peer-details.js`: Peer information display
|
||||||
|
|
||||||
|
**Supporting Modules**:
|
||||||
|
- `data-processor.js`: Data transformation and processing
|
||||||
|
- `websocket-client.js`: WebSocket connection management
|
||||||
|
- `utils.js`: Utility functions
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The plugin uses the standard P2NS plugin configuration in `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "peer.visualize",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"domain": "peer.visualize",
|
||||||
|
"description": "Interactive real-time visualization of P2NS peer connections and system architecture",
|
||||||
|
"author": "P2NS",
|
||||||
|
"homepage": "https://github.com/p2ns/p2ns",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {},
|
||||||
|
"www": "www"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### External Libraries
|
||||||
|
|
||||||
|
- **D3.js v7**: Graph visualization and force simulation
|
||||||
|
- **Chart.js v4.4.0**: Charts and metrics visualization
|
||||||
|
- **Tailwind CSS**: Styling framework
|
||||||
|
|
||||||
|
### P2NS SDK
|
||||||
|
|
||||||
|
The plugin uses the P2NS Plugin SDK to access:
|
||||||
|
|
||||||
|
- `sdk.state`: System state (peers, domains, metrics)
|
||||||
|
- `sdk.domains`: Domain management and information
|
||||||
|
- `sdk.peers`: Peer information and metrics
|
||||||
|
- `sdk.metrics`: System metrics
|
||||||
|
- `sdk.router`: HTTP request/response handling
|
||||||
|
- `sdk.log`: Logging
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
- Modern browsers with ES6+ support
|
||||||
|
- WebSocket support required
|
||||||
|
- Canvas API for charts
|
||||||
|
- SVG support for graphs
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- **Update Frequency**: System updates are sent every 5 seconds. This can be adjusted in `index.js` if needed.
|
||||||
|
- **Data Limits**: Peer history is limited to the last 50 entries per peer to prevent memory issues.
|
||||||
|
- **Graph Rendering**: Large networks (100+ nodes) may experience reduced performance. Consider using filters to reduce visible nodes.
|
||||||
|
- **WebSocket Connections**: Multiple browser tabs will each maintain a WebSocket connection.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### WebSocket Connection Issues
|
||||||
|
|
||||||
|
- **Status shows "Disconnected"**: Check browser console for WebSocket errors. Ensure the plugin is running and WebSocket server is initialized.
|
||||||
|
- **No real-time updates**: Verify WebSocket connection in browser DevTools → Network → WS tab.
|
||||||
|
|
||||||
|
### Visualization Not Rendering
|
||||||
|
|
||||||
|
- **Graph appears empty**: Check browser console for JavaScript errors. Ensure D3.js is loaded.
|
||||||
|
- **Charts not displaying**: Verify Chart.js is loaded and canvas elements are visible.
|
||||||
|
|
||||||
|
### Data Not Updating
|
||||||
|
|
||||||
|
- **Stale data**: Check that the plugin's `onInit()` completed successfully. View plugin logs in the admin interface.
|
||||||
|
- **Missing peers/domains**: Verify P2NS is running and has active peers/domains. Check system state via `/api/system`.
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
|
||||||
|
- **Slow rendering**: Reduce visible nodes using filters. Close other browser tabs.
|
||||||
|
- **High CPU usage**: Large networks may require more processing. Consider using hierarchical layout for better performance.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
peer.visualize/
|
||||||
|
├── config.json # Plugin configuration
|
||||||
|
├── index.js # Plugin handler (backend)
|
||||||
|
├── www/ # Frontend files
|
||||||
|
│ ├── index.html # Main HTML
|
||||||
|
│ ├── css/ # Stylesheets
|
||||||
|
│ │ ├── style.css
|
||||||
|
│ │ └── tailwind.css
|
||||||
|
│ └── js/ # JavaScript modules
|
||||||
|
│ ├── app.js # Main application
|
||||||
|
│ ├── data-processor.js
|
||||||
|
│ ├── websocket-client.js
|
||||||
|
│ ├── utils.js
|
||||||
|
│ └── visualization/ # Visualization components
|
||||||
|
│ ├── dashboard.js
|
||||||
|
│ ├── domain-map.js
|
||||||
|
│ ├── force-graph.js
|
||||||
|
│ ├── hierarchical.js
|
||||||
|
│ └── peer-details.js
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifying Update Frequency
|
||||||
|
|
||||||
|
Edit `index.js` and change the interval in `setupPeriodicUpdates()`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
updateInterval = setInterval(async () => {
|
||||||
|
// ... update logic
|
||||||
|
}, 5000); // Change 5000 to desired milliseconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding New Views
|
||||||
|
|
||||||
|
1. Add view HTML to `index.html`
|
||||||
|
2. Add view tab in sidebar
|
||||||
|
3. Add view initialization in `app.js`
|
||||||
|
4. Create visualization component if needed
|
||||||
|
|
||||||
|
### Customizing Visualizations
|
||||||
|
|
||||||
|
Each visualization component is self-contained:
|
||||||
|
|
||||||
|
- `ForceGraph`: Force-directed graph with D3.js
|
||||||
|
- `HierarchicalGraph`: Tree layout visualization
|
||||||
|
- `DomainMap`: Domain-specific visualization
|
||||||
|
- `Dashboard`: Chart.js-based metrics
|
||||||
|
|
||||||
|
Modify the respective files to customize appearance or behavior.
|
||||||
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 1001 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 796 KiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 5.0 MiB |
@@ -0,0 +1,57 @@
|
|||||||
|
// Main admin entry point - imports all modules and sets up console overrides
|
||||||
|
const { adminWss, broadcast, closeAllWebSockets } = require('./admin/admin-backend/websocket');
|
||||||
|
const { loadHolesailServers, startHolesailServer, saveHolesailServers } = require('./admin/admin-backend/holesail-servers');
|
||||||
|
const { loadHolesailClients, startForkedHolesailClient, saveHolesailClients } = require('./admin/admin-backend/holesail-clients');
|
||||||
|
const { loadSelectorCache, saveSelectorCache, loadBlockedPeers, saveBlockedPeers, loadPeerMetrics, savePeerMetrics, loadPeerHistory, savePeerHistory } = require('./admin/admin-backend/cache');
|
||||||
|
const { settingsMetadata, envWhitelist } = require('./admin/admin-backend/settings');
|
||||||
|
const { handleAdminRequest } = require('./admin/admin-backend/routes');
|
||||||
|
|
||||||
|
// Save original console methods for restoration
|
||||||
|
const originalConsoleLog = console.log;
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
const originalConsoleWarn = console.warn;
|
||||||
|
const originalConsoleDebug = console.debug;
|
||||||
|
|
||||||
|
// Override console methods to broadcast to WebSocket clients
|
||||||
|
console.log = (...args) => {
|
||||||
|
originalConsoleLog(...args);
|
||||||
|
broadcast({ type: 'log', level: 'info', message: args.join(' ') });
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args) => {
|
||||||
|
originalConsoleError(...args);
|
||||||
|
broadcast({ type: 'log', level: 'error', message: args.join(' ') });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to restore original console methods
|
||||||
|
function restoreConsoleMethods() {
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
console.warn = originalConsoleWarn;
|
||||||
|
console.debug = originalConsoleDebug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export all public APIs for backward compatibility
|
||||||
|
module.exports = {
|
||||||
|
handleAdminRequest,
|
||||||
|
adminWss,
|
||||||
|
broadcast,
|
||||||
|
loadHolesailServers,
|
||||||
|
startHolesailServer,
|
||||||
|
saveHolesailServers,
|
||||||
|
loadHolesailClients,
|
||||||
|
startForkedHolesailClient,
|
||||||
|
saveHolesailClients,
|
||||||
|
loadSelectorCache,
|
||||||
|
saveSelectorCache,
|
||||||
|
loadBlockedPeers,
|
||||||
|
saveBlockedPeers,
|
||||||
|
loadPeerMetrics,
|
||||||
|
savePeerMetrics,
|
||||||
|
loadPeerHistory,
|
||||||
|
savePeerHistory,
|
||||||
|
restoreConsoleMethods,
|
||||||
|
closeAllWebSockets,
|
||||||
|
settingsMetadata,
|
||||||
|
envWhitelist
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
const net = require('net');
|
||||||
|
const Holesail = require('holesail');
|
||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const { logDebug, logInfo, logError, logWarn } = require('../../infrastructure/logger');
|
||||||
|
const { checkPortResponsive, parseMinutesToMs } = require('../../infrastructure/utils');
|
||||||
|
const { checkPortAvailability, freePort } = require('../../maintenance/cleanup');
|
||||||
|
const { trackHolesailEvent } = require('../../maintenance/metrics');
|
||||||
|
|
||||||
|
// Start Holesail client
|
||||||
|
async function startHolesailClient(domain, hash, ip, port, persistent = false) {
|
||||||
|
if (!ip) {
|
||||||
|
logDebug('Holesail', `Invalid IP for domain: ${domain}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${domain}:${port}`;
|
||||||
|
if (state.holesails.has(key)) {
|
||||||
|
logDebug('Holesail', `Holesail client already exists for ${key}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let startPromise = state.starting.get(key);
|
||||||
|
if (!startPromise) {
|
||||||
|
startPromise = (async () => {
|
||||||
|
logInfo('Holesail', `Starting Holesail client for domain: ${domain}, hash: ${hash}, IP: ${ip}, Port: ${port}`);
|
||||||
|
try {
|
||||||
|
// Check port availability
|
||||||
|
try {
|
||||||
|
await checkPortAvailability(ip, port);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Holesail', `Port check failed initially for ${ip}:${port}: ${err.message}. Attempting to free the port.`);
|
||||||
|
const freed = await freePort(ip, port);
|
||||||
|
if (!freed) {
|
||||||
|
throw new Error(`Unable to free port ${port} on ${ip} after attempt`);
|
||||||
|
}
|
||||||
|
logInfo('Holesail', `Successfully freed port ${port} on ${ip}`);
|
||||||
|
}
|
||||||
|
const holesail = new Holesail({
|
||||||
|
client: true,
|
||||||
|
key: hash,
|
||||||
|
port: port,
|
||||||
|
host: ip,
|
||||||
|
log: false
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
await holesail.ready();
|
||||||
|
state.holesails.set(key, holesail);
|
||||||
|
// Track start time for uptime calculation
|
||||||
|
if (!state.holesailStartTimes) {
|
||||||
|
state.holesailStartTimes = new Map();
|
||||||
|
}
|
||||||
|
state.holesailStartTimes.set(key, Date.now());
|
||||||
|
trackHolesailEvent('client', 'start', 'tcp', null); // Protocol not available here
|
||||||
|
logInfo('Holesail', `Holesail client for ${key} connected on ${ip}:${port}`);
|
||||||
|
if (!persistent) {
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
// Check if connection still exists before cleaning up (may have been cleaned up already)
|
||||||
|
if (!state.holesails.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
logInfo('Holesail', `Closing Holesail client for ${key}`);
|
||||||
|
try {
|
||||||
|
await holesail.close();
|
||||||
|
state.holesails.delete(key);
|
||||||
|
if (state.holesailStartTimes) {
|
||||||
|
state.holesailStartTimes.delete(key);
|
||||||
|
}
|
||||||
|
if (state.holesailClientTimeouts) {
|
||||||
|
state.holesailClientTimeouts.delete(key);
|
||||||
|
}
|
||||||
|
trackHolesailEvent('client', 'stop', 'tcp', duration);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Error closing Holesail client for ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, parseMinutesToMs(process.env.HOLESAIL_TIMEOUT || '5'));
|
||||||
|
// Store timeout reference for cleanup
|
||||||
|
if (!state.holesailClientTimeouts) {
|
||||||
|
state.holesailClientTimeouts = new Map();
|
||||||
|
}
|
||||||
|
state.holesailClientTimeouts.set(key, timeout);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Error connecting Holesail client for ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to start Holesail client for ${key}: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
state.starting.delete(key);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
state.starting.set(key, startPromise);
|
||||||
|
}
|
||||||
|
await startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart Holesail client
|
||||||
|
async function restartHolesailClient(domain, hash, ip, port) {
|
||||||
|
if (!ip) {
|
||||||
|
logDebug('Holesail', `Invalid IP for domain: ${domain}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${domain}:${port}`;
|
||||||
|
const isResponsive = await checkPortResponsive(ip, port);
|
||||||
|
if (isResponsive) {
|
||||||
|
logDebug('Holesail', `Port ${port} on ${ip} is responsive, using the existing connection.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logInfo('Holesail', `Port ${port} on ${ip} is unresponsive, closing and recreating the Holesail client`);
|
||||||
|
const existing = state.holesails.get(key);
|
||||||
|
if (existing) {
|
||||||
|
try {
|
||||||
|
await existing.close();
|
||||||
|
logInfo('Holesail', `Closed existing Holesail client for ${key}`);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Error closing existing Holesail client for ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
state.holesails.delete(key);
|
||||||
|
}
|
||||||
|
if (state.starting.has(key)) {
|
||||||
|
await state.starting.get(key);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// In restart, also attempt to free if needed, but since we closed, it should be free, but to be safe
|
||||||
|
try {
|
||||||
|
await checkPortAvailability(ip, port);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Holesail', `Port still in use after close for ${ip}:${port}: ${err.message}. Attempting to free.`);
|
||||||
|
const freed = await freePort(ip, port);
|
||||||
|
if (!freed) {
|
||||||
|
throw new Error(`Unable to free port ${port} on ${ip} during restart`);
|
||||||
|
}
|
||||||
|
logInfo('Holesail', `Successfully freed port ${port} on ${ip} during restart`);
|
||||||
|
}
|
||||||
|
await startHolesailClient(domain, hash, ip, port, true); // Persistent for restarts
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to restart Holesail client for ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startHolesailClient, restartHolesailClient };
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const { logDebug, logError, logInfo } = require('../../infrastructure/logger');
|
||||||
|
|
||||||
|
const selectorCacheFile = process.env.SELECTOR_CACHE_FILE || './cache/selector_cache.json';
|
||||||
|
const localDnsFile = process.env.LOCAL_DNS_FILE || 'cache/local_dns.json';
|
||||||
|
const blockedPeersFile = process.env.BLOCKED_PEERS_FILE || './cache/blocked_peers.json';
|
||||||
|
const peerMetricsFile = process.env.PEER_METRICS_FILE || './cache/peer_metrics.json';
|
||||||
|
const peerHistoryFile = process.env.PEER_HISTORY_FILE || './cache/peer_history.json';
|
||||||
|
|
||||||
|
async function loadSelectorCache() {
|
||||||
|
try {
|
||||||
|
if (await fs.access(selectorCacheFile).then(() => true).catch(() => false)) {
|
||||||
|
const data = JSON.parse(await fs.readFile(selectorCacheFile, 'utf8'));
|
||||||
|
state.versionPreferences = new Map(Object.entries(data));
|
||||||
|
logInfo('Admin', 'Loaded version preferences from selector_cache.json');
|
||||||
|
} else {
|
||||||
|
state.versionPreferences = new Map();
|
||||||
|
logInfo('Admin', 'No selector_cache.json found, initializing empty version preferences');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to load selector_cache.json: ${err.message}`);
|
||||||
|
state.versionPreferences = new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelectorCache() {
|
||||||
|
try {
|
||||||
|
const data = Object.fromEntries(state.versionPreferences);
|
||||||
|
await fs.writeFile(selectorCacheFile, JSON.stringify(data, null, 2));
|
||||||
|
logDebug('Admin', 'Saved version preferences to selector_cache.json');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to save selector_cache.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalDnsRecords() {
|
||||||
|
try {
|
||||||
|
if (await fs.access(localDnsFile).then(() => true).catch(() => false)) {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(localDnsFile, 'utf8'));
|
||||||
|
// Ensure parsed result is an array
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
logError('Admin', `Local DNS records file does not contain an array, resetting to empty array`);
|
||||||
|
state.localDnsRecords = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ensure each record has a 'type' and 'class' field
|
||||||
|
state.localDnsRecords = parsed.map(record => ({
|
||||||
|
...record,
|
||||||
|
class: record.class || 'IN',
|
||||||
|
type: record.type || 'A' // Default to A if type is missing
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
state.localDnsRecords = [];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to load local DNS records: ${err.message}`);
|
||||||
|
state.localDnsRecords = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBlockedPeers() {
|
||||||
|
try {
|
||||||
|
if (await fs.access(blockedPeersFile).then(() => true).catch(() => false)) {
|
||||||
|
const data = JSON.parse(await fs.readFile(blockedPeersFile, 'utf8'));
|
||||||
|
// Ensure parsed result is an array
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
logError('Admin', `Blocked peers file does not contain an array, resetting to empty array`);
|
||||||
|
state.blockedPeers = new Set();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.blockedPeers = new Set(data);
|
||||||
|
logInfo('Admin', `Loaded ${data.length} blocked peer(s) from blocked_peers.json`);
|
||||||
|
} else {
|
||||||
|
state.blockedPeers = new Set();
|
||||||
|
logInfo('Admin', 'No blocked_peers.json found, initializing empty blocked peers list');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to load blocked_peers.json: ${err.message}`);
|
||||||
|
state.blockedPeers = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBlockedPeers() {
|
||||||
|
try {
|
||||||
|
const data = Array.from(state.blockedPeers || new Set());
|
||||||
|
await fs.writeFile(blockedPeersFile, JSON.stringify(data, null, 2));
|
||||||
|
logDebug('Admin', `Saved ${data.length} blocked peer(s) to blocked_peers.json`);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to save blocked_peers.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPeerMetrics() {
|
||||||
|
try {
|
||||||
|
if (await fs.access(peerMetricsFile).then(() => true).catch(() => false)) {
|
||||||
|
const data = JSON.parse(await fs.readFile(peerMetricsFile, 'utf8'));
|
||||||
|
// Ensure parsed result is an object
|
||||||
|
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
||||||
|
logError('Admin', `Peer metrics file does not contain an object, resetting to empty map`);
|
||||||
|
state.peerMetrics = new Map();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Convert object to Map and merge with existing metrics
|
||||||
|
const loadedMetrics = new Map(Object.entries(data));
|
||||||
|
// Merge with existing metrics (existing takes precedence for same peerId)
|
||||||
|
for (const [peerId, metrics] of loadedMetrics) {
|
||||||
|
if (!state.peerMetrics.has(peerId)) {
|
||||||
|
state.peerMetrics.set(peerId, metrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logInfo('Admin', `Loaded peer metrics for ${loadedMetrics.size} peer(s) from peer_metrics.json`);
|
||||||
|
} else {
|
||||||
|
logInfo('Admin', 'No peer_metrics.json found, initializing empty peer metrics');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to load peer_metrics.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePeerMetrics() {
|
||||||
|
try {
|
||||||
|
// Convert Map to object for JSON serialization
|
||||||
|
const data = Object.fromEntries(state.peerMetrics || new Map());
|
||||||
|
await fs.writeFile(peerMetricsFile, JSON.stringify(data, null, 2));
|
||||||
|
logDebug('Admin', `Saved peer metrics for ${Object.keys(data).length} peer(s) to peer_metrics.json`);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to save peer_metrics.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPeerHistory() {
|
||||||
|
try {
|
||||||
|
if (await fs.access(peerHistoryFile).then(() => true).catch(() => false)) {
|
||||||
|
const data = JSON.parse(await fs.readFile(peerHistoryFile, 'utf8'));
|
||||||
|
// Ensure parsed result is an object
|
||||||
|
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
||||||
|
logError('Admin', `Peer history file does not contain an object, resetting to empty map`);
|
||||||
|
state.peerHistory = new Map();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Convert object to Map, limiting each history array to last 50 items
|
||||||
|
const loadedHistory = new Map();
|
||||||
|
for (const [peerId, history] of Object.entries(data)) {
|
||||||
|
if (Array.isArray(history)) {
|
||||||
|
// Keep only the last 50 items
|
||||||
|
loadedHistory.set(peerId, history.slice(-50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Merge with existing history (existing takes precedence for same peerId)
|
||||||
|
for (const [peerId, history] of loadedHistory) {
|
||||||
|
if (!state.peerHistory.has(peerId)) {
|
||||||
|
state.peerHistory.set(peerId, history);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logInfo('Admin', `Loaded peer history for ${loadedHistory.size} peer(s) from peer_history.json`);
|
||||||
|
} else {
|
||||||
|
logInfo('Admin', 'No peer_history.json found, initializing empty peer history');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to load peer_history.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePeerHistory() {
|
||||||
|
try {
|
||||||
|
// Convert Map to object for JSON serialization, limiting each history to last 50 items
|
||||||
|
const data = {};
|
||||||
|
for (const [peerId, history] of (state.peerHistory || new Map())) {
|
||||||
|
if (Array.isArray(history) && history.length > 0) {
|
||||||
|
data[peerId] = history.slice(-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fs.writeFile(peerHistoryFile, JSON.stringify(data, null, 2));
|
||||||
|
logDebug('Admin', `Saved peer history for ${Object.keys(data).length} peer(s) to peer_history.json`);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to save peer_history.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on load
|
||||||
|
loadSelectorCache();
|
||||||
|
loadLocalDnsRecords();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadSelectorCache,
|
||||||
|
saveSelectorCache,
|
||||||
|
loadLocalDnsRecords,
|
||||||
|
loadBlockedPeers,
|
||||||
|
saveBlockedPeers,
|
||||||
|
loadPeerMetrics,
|
||||||
|
savePeerMetrics,
|
||||||
|
loadPeerHistory,
|
||||||
|
savePeerHistory
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const pathModule = require('path');
|
||||||
|
const child_process = require('child_process');
|
||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const { logDebug, logError, logInfo, logWarn } = require('../../infrastructure/logger');
|
||||||
|
// Lazy load createInterfaceForDomain to avoid circular dependency
|
||||||
|
// const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
|
||||||
|
const { ensurePortFree } = require('./port-management');
|
||||||
|
const { startHolesailClient } = require('./admin-holesail');
|
||||||
|
const { broadcast } = require('./websocket');
|
||||||
|
|
||||||
|
const holesailClientsFile = process.env.HOLESAIL_CLIENTS_FILE || './cache/holesail_clients.json';
|
||||||
|
|
||||||
|
async function loadHolesailClients() {
|
||||||
|
state.holesailClientChildren = new Map();
|
||||||
|
state.holesailClientOpts = new Map();
|
||||||
|
state.holesailClientInfos = new Map();
|
||||||
|
const file = holesailClientsFile;
|
||||||
|
try {
|
||||||
|
if (await fs.access(file).then(() => true).catch(() => false)) {
|
||||||
|
const data = JSON.parse(await fs.readFile(file, 'utf8'));
|
||||||
|
const promises = (data.clients || []).map(async (s) => {
|
||||||
|
const id = s.id;
|
||||||
|
const opts = s.opts;
|
||||||
|
try {
|
||||||
|
if (!state.domainToIPMap.has(opts.domain)) {
|
||||||
|
// Lazy load to avoid circular dependency
|
||||||
|
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
|
||||||
|
await createInterfaceForDomain(opts.domain);
|
||||||
|
logDebug('Holesail', `Assigned IP to ${opts.domain} for client ${id}: ${state.domainToIPMap.get(opts.domain)}`);
|
||||||
|
}
|
||||||
|
const ip = state.domainToIPMap.get(opts.domain);
|
||||||
|
const portFree = await ensurePortFree(ip, opts.port);
|
||||||
|
if (!portFree) {
|
||||||
|
throw new Error(`Unable to ensure port ${opts.port} free on ${ip}`);
|
||||||
|
}
|
||||||
|
await startForkedHolesailClient(id, opts);
|
||||||
|
logInfo('Holesail', `Restored client ${id} for domain ${opts.domain} on port ${opts.port}`);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to restore client ${id} for ${opts.domain}:${opts.port}: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
|
||||||
|
// Restore claim records from cached clients
|
||||||
|
// Wait a moment for dnsPass to be fully ready, then restore claims
|
||||||
|
if (state.dnsPass) {
|
||||||
|
try {
|
||||||
|
await state.dnsPass.ready();
|
||||||
|
// Small delay to ensure all systems are initialized
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
} catch (err) {
|
||||||
|
logDebug('Holesail', `dnsPass not ready yet: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await restoreClaimsFromClients(data.clients || []);
|
||||||
|
} else {
|
||||||
|
logInfo('Holesail', 'No holesail_clients.json found, skipping restore');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to load holesail_clients.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore claim records from cached holesail clients
|
||||||
|
* Groups clients by domain and updates claim records for domains we own
|
||||||
|
*/
|
||||||
|
async function restoreClaimsFromClients(clients) {
|
||||||
|
if (!clients || clients.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if dnsPass is initialized
|
||||||
|
if (!state.dnsPass) {
|
||||||
|
logDebug('Holesail', 'dnsPass not initialized, skipping claim restoration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for dnsPass to be ready
|
||||||
|
await state.dnsPass.ready();
|
||||||
|
|
||||||
|
// Lazy load required functions to avoid circular dependencies
|
||||||
|
const { getConsensusState, getAllEntries, updateClaimClients } = require('../../core/core');
|
||||||
|
const { getPersistentPublicKey } = require('../../infrastructure/utils');
|
||||||
|
|
||||||
|
const localWriter = getPersistentPublicKey();
|
||||||
|
if (!localWriter) {
|
||||||
|
logDebug('Holesail', 'No persistent public key available, skipping claim restoration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo('Holesail', `Starting claim restoration for ${clients.length} cached client(s)`);
|
||||||
|
|
||||||
|
// Group clients by domain
|
||||||
|
const clientsByDomain = new Map();
|
||||||
|
for (const client of clients) {
|
||||||
|
const domain = client.opts?.domain;
|
||||||
|
if (!domain) continue;
|
||||||
|
|
||||||
|
if (!clientsByDomain.has(domain)) {
|
||||||
|
clientsByDomain.set(domain, []);
|
||||||
|
}
|
||||||
|
clientsByDomain.get(domain).push(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug('Holesail', `Grouped clients into ${clientsByDomain.size} domain(s)`);
|
||||||
|
|
||||||
|
// For each domain, check if we own it and restore claims
|
||||||
|
for (const [domain, domainClients] of clientsByDomain.entries()) {
|
||||||
|
try {
|
||||||
|
// Check if we are the resolved claimant for this domain
|
||||||
|
const consensusState = await getConsensusState(domain);
|
||||||
|
if (!consensusState.resolvedClaimant || consensusState.resolvedClaimant !== localWriter) {
|
||||||
|
logDebug('Holesail', `Skipping claim restoration for ${domain} - not the resolved claimant (claimant: ${consensusState.resolvedClaimant || 'none'})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logDebug('Holesail', `Processing claim restoration for ${domain} (we are the claimant)`);
|
||||||
|
|
||||||
|
// Extract service information from client IDs
|
||||||
|
// Client ID format: domain_servicename (where domain has dots replaced with underscores)
|
||||||
|
const domainPrefix = domain.replace(/\./g, '_');
|
||||||
|
const claimClients = [];
|
||||||
|
|
||||||
|
for (const client of domainClients) {
|
||||||
|
const id = client.id;
|
||||||
|
const opts = client.opts;
|
||||||
|
|
||||||
|
// Check if ID follows the domain_servicename pattern
|
||||||
|
if (id && id.startsWith(domainPrefix + '_')) {
|
||||||
|
const serviceName = id.substring(domainPrefix.length + 1);
|
||||||
|
|
||||||
|
// Only include clients with valid service names
|
||||||
|
if (serviceName && opts.key && opts.port) {
|
||||||
|
claimClients.push({
|
||||||
|
name: serviceName,
|
||||||
|
key: opts.key,
|
||||||
|
port: opts.port,
|
||||||
|
protocol: opts.protocol || 'tcp'
|
||||||
|
});
|
||||||
|
logDebug('Holesail', `Found client for ${domain}: service=${serviceName}, port=${opts.port}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logDebug('Holesail', `Client ${id} for ${domain} does not follow domain_servicename pattern (expected prefix: ${domainPrefix}_)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update claim record if we have clients to restore
|
||||||
|
if (claimClients.length > 0) {
|
||||||
|
logInfo('Holesail', `Updating claim for ${domain} with ${claimClients.length} client(s): ${claimClients.map(c => c.name).join(', ')}`);
|
||||||
|
const success = await updateClaimClients(domain, localWriter, claimClients);
|
||||||
|
if (success) {
|
||||||
|
logInfo('Holesail', `Successfully restored claim record for ${domain} with ${claimClients.length} client(s)`);
|
||||||
|
} else {
|
||||||
|
logWarn('Holesail', `Failed to restore claim record for ${domain} - claim may not exist yet or update failed. Claimant: ${localWriter}`);
|
||||||
|
// Try to check if claim exists
|
||||||
|
try {
|
||||||
|
const claimKey = `claim:${domain}:${localWriter}`;
|
||||||
|
const allEntries = await getAllEntries(state.dnsPass, false);
|
||||||
|
const claimExists = allEntries.some(e => e.key === claimKey);
|
||||||
|
if (claimExists) {
|
||||||
|
logWarn('Holesail', `Claim exists for ${domain} but updateClaimClients returned false - this may indicate a sync issue`);
|
||||||
|
} else {
|
||||||
|
logWarn('Holesail', `Claim does not exist for ${domain} by ${localWriter} - cannot restore clients`);
|
||||||
|
}
|
||||||
|
} catch (checkErr) {
|
||||||
|
logWarn('Holesail', `Error checking claim existence: ${checkErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logDebug('Holesail', `No clients with service names found for ${domain}, skipping claim restoration`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Holesail', `Error restoring claim for ${domain}: ${err.message}`);
|
||||||
|
// Continue with other domains even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Holesail', `Error during claim restoration: ${err.message}`);
|
||||||
|
// Don't fail startup if claim restoration fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startForkedHolesailClient(id, opts) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!state.domainToIPMap.has(opts.domain)) {
|
||||||
|
reject(new Error(`No IP assigned for domain ${opts.domain}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ip = state.domainToIPMap.get(opts.domain);
|
||||||
|
const childOpts = {
|
||||||
|
client: true,
|
||||||
|
key: opts.key,
|
||||||
|
port: opts.port,
|
||||||
|
host: ip,
|
||||||
|
log: false,
|
||||||
|
protocol: opts.protocol || 'tcp'
|
||||||
|
};
|
||||||
|
const child = child_process.fork(pathModule.join(__dirname, '..', '..', 'networking', 'holesail_child.js'));
|
||||||
|
child.on('error', (err) => {
|
||||||
|
logError('Holesail', `Child error for client ${id}: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
logInfo('Holesail', `Child exited for client ${id} with code ${code}`);
|
||||||
|
state.holesailClientChildren.delete(id);
|
||||||
|
state.holesailClientInfos.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
});
|
||||||
|
child.on('message', async (msg) => {
|
||||||
|
if (msg.type === 'ready') {
|
||||||
|
try {
|
||||||
|
state.holesailClientInfos.set(id, msg.info);
|
||||||
|
await startHolesailClient(opts.domain, opts.key, ip, opts.port, true, opts.protocol);
|
||||||
|
state.holesailClientChildren.set(id, child);
|
||||||
|
state.holesailClientOpts.set(id, opts);
|
||||||
|
state.holesailChildStartTimes.set(id, Date.now());
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
resolve({ id, info: msg.info });
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to start Holesail client for ${id}: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'log') {
|
||||||
|
broadcast({ type: 'holesail-log', id, level: msg.level, message: msg.message });
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
logError('Holesail', `Child error message for client ${id}: ${msg.message}`);
|
||||||
|
reject(new Error(msg.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.send({ type: 'start', opts: childOpts });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHolesailClients() {
|
||||||
|
const file = holesailClientsFile;
|
||||||
|
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => ({ id, opts }));
|
||||||
|
try {
|
||||||
|
await fs.writeFile(file, JSON.stringify({ clients }, null, 2));
|
||||||
|
logDebug('Holesail', 'Saved holesail_clients.json');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to save holesail_clients.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadHolesailClients,
|
||||||
|
startForkedHolesailClient,
|
||||||
|
saveHolesailClients
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const pathModule = require('path');
|
||||||
|
const child_process = require('child_process');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const z32 = require('z32');
|
||||||
|
const libKeys = require('hyper-cmd-lib-keys');
|
||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const { logDebug, logError, logInfo } = require('../../infrastructure/logger');
|
||||||
|
const { trackHolesailEvent } = require('../../maintenance/metrics');
|
||||||
|
const { broadcast } = require('./websocket');
|
||||||
|
|
||||||
|
const holesailServersFile = process.env.HOLESAIL_SERVERS_FILE || './cache/holesail_servers.json';
|
||||||
|
|
||||||
|
async function loadHolesailServers() {
|
||||||
|
state.holesailChildren = new Map();
|
||||||
|
state.holesailOpts = new Map();
|
||||||
|
state.holesailInfos = new Map();
|
||||||
|
const file = holesailServersFile;
|
||||||
|
try {
|
||||||
|
if (await fs.access(file).then(() => true).catch(() => false)) {
|
||||||
|
const data = JSON.parse(await fs.readFile(file, 'utf8'));
|
||||||
|
const promises = (data.servers || []).map(async (s) => {
|
||||||
|
const id = s.id;
|
||||||
|
const opts = s.opts;
|
||||||
|
try {
|
||||||
|
logDebug('Admin', `Starting Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`);
|
||||||
|
await startHolesailServer(id, opts);
|
||||||
|
logInfo('Holesail', `Restored server ${id} (${opts.name || 'unnamed'}) on port ${opts.port}`);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to restore server ${id} on port ${opts.port}: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
|
} else {
|
||||||
|
logInfo('Holesail', 'No holesail_servers.json found, skipping restore');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to load holesail_servers.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHolesailServer(id, opts) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!opts.key) {
|
||||||
|
if (opts.secure) {
|
||||||
|
opts.key = libKeys.randomBytes(32).toString('hex');
|
||||||
|
} else {
|
||||||
|
opts.key = z32.encode(crypto.randomBytes(32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const child = child_process.fork(pathModule.join(__dirname, '..', '..', 'networking', 'holesail_child.js'));
|
||||||
|
child.on('error', (err) => {
|
||||||
|
logError('Holesail', `Child error for server ${id}: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
logInfo('Holesail', `Child exited for server ${id} with code ${code}`);
|
||||||
|
const opts = state.holesailOpts.get(id);
|
||||||
|
const protocol = opts?.udp ? 'udp' : 'tcp';
|
||||||
|
trackHolesailEvent('server', 'stop', protocol, null);
|
||||||
|
state.holesailChildren.delete(id);
|
||||||
|
state.holesailInfos.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
broadcast({ type: 'update-stats' });
|
||||||
|
});
|
||||||
|
child.on('message', (msg) => {
|
||||||
|
if (msg.type === 'ready') {
|
||||||
|
state.holesailInfos.set(id, msg.info);
|
||||||
|
const protocol = opts.udp ? 'udp' : 'tcp';
|
||||||
|
trackHolesailEvent('server', 'start', protocol, null);
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
broadcast({ type: 'update-stats' });
|
||||||
|
resolve({ id, info: msg.info });
|
||||||
|
} else if (msg.type === 'log') {
|
||||||
|
broadcast({ type: 'holesail-log', id, level: msg.level, message: msg.message });
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
logError('Holesail', `Child error message for server ${id}: ${msg.message}`);
|
||||||
|
reject(new Error(msg.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.send({ type: 'start', opts: { server: true, ...opts, log: false } });
|
||||||
|
state.holesailChildren.set(id, child);
|
||||||
|
state.holesailOpts.set(id, opts);
|
||||||
|
state.holesailChildStartTimes.set(id, Date.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHolesailServers() {
|
||||||
|
const file = holesailServersFile;
|
||||||
|
const servers = Array.from(state.holesailOpts.entries()).map(([id, opts]) => ({ id, opts }));
|
||||||
|
try {
|
||||||
|
await fs.writeFile(file, JSON.stringify({ servers }, null, 2));
|
||||||
|
logDebug('Holesail', 'Saved holesail_servers.json');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to save holesail_servers.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadHolesailServers,
|
||||||
|
startHolesailServer,
|
||||||
|
saveHolesailServers
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
const net = require('net');
|
||||||
|
const dgram = require('dgram');
|
||||||
|
const { cleanupInterfaces, freePort } = require('../../maintenance/cleanup');
|
||||||
|
const { logDebug, logError, logInfo, logWarn } = require('../../infrastructure/logger');
|
||||||
|
|
||||||
|
async function waitForPortRelease(host, port, maxAttempts = 10, delayMs = 1000) {
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', (err) => {
|
||||||
|
server.close();
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`TCP port ${port} on ${host} is already in use`));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
server.listen(port, host);
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
socket.once('error', (err) => {
|
||||||
|
socket.close();
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`UDP port ${port} on ${host} is already in use`));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.once('listening', () => {
|
||||||
|
socket.close();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
socket.bind(port, host);
|
||||||
|
});
|
||||||
|
logDebug('Admin', `Port ${port} on ${host} is now free (attempt ${attempt})`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logDebug('Admin', `Port ${port} on ${host} still in use (attempt ${attempt}): ${err.message}`);
|
||||||
|
if (attempt === maxAttempts) {
|
||||||
|
logWarn('Admin', `Port ${port} on ${host} still in use after ${maxAttempts} attempts`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPortAvailability(host, port) {
|
||||||
|
const tcpPromise = new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', (err) => {
|
||||||
|
server.close();
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`TCP port ${port} on ${host} is already in use`));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close(() => {
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(port, host);
|
||||||
|
});
|
||||||
|
const udpPromise = new Promise((resolve, reject) => {
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
socket.once('error', (err) => {
|
||||||
|
socket.close();
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`UDP port ${port} on ${host} is already in use`));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.once('listening', () => {
|
||||||
|
socket.close();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
socket.bind(port, host);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await Promise.all([tcpPromise, udpPromise]);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePortFree(host, port) {
|
||||||
|
const maxAttempts = 3;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
await checkPortAvailability(host, port);
|
||||||
|
logInfo('Admin', `Port ${port} on ${host} is free`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Port ${port} on ${host} in use (attempt ${attempt}/${maxAttempts}): ${err.message}. Attempting to free it.`);
|
||||||
|
const freed = await freePort(host, port);
|
||||||
|
if (!freed) {
|
||||||
|
logError('Admin', `Failed to free port ${port} on ${host} on attempt ${attempt}`);
|
||||||
|
if (attempt === maxAttempts) {
|
||||||
|
logError('Admin', `Port ${port} on ${host} could not be freed after ${maxAttempts} attempts`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logInfo('Admin', `Freed port ${port} on ${host}. Waiting for release...`);
|
||||||
|
const released = await waitForPortRelease(host, port, 10, 1000);
|
||||||
|
if (!released) {
|
||||||
|
logWarn('Admin', `Port ${port} on ${host} still not released after waiting on attempt ${attempt}`);
|
||||||
|
if (attempt === maxAttempts) {
|
||||||
|
logError('Admin', `Port ${port} on ${host} could not be released after ${maxAttempts} attempts`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logInfo('Admin', `Port ${port} on ${host} successfully released on attempt ${attempt}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
waitForPortRelease,
|
||||||
|
checkPortAvailability,
|
||||||
|
ensurePortFree
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
const { createBackup, listBackups, restoreBackup, cleanupOldBackups } = require('../../../maintenance/backup');
|
||||||
|
const { logError } = require('../../../infrastructure/logger');
|
||||||
|
const { trackRequest, trackRequestWithTiming } = require('../../../maintenance/metrics');
|
||||||
|
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
async function handleBackupsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
// GET /api/backups - List all backups
|
||||||
|
if (method === 'GET' && urlPath === '/api/backups') {
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const backups = await listBackups();
|
||||||
|
|
||||||
|
// Add formatted size to each backup (size is already calculated in listBackups for tar.gz)
|
||||||
|
const backupsWithSize = backups.map((backup) => {
|
||||||
|
return {
|
||||||
|
...backup,
|
||||||
|
size: backup.size || 0,
|
||||||
|
sizeFormatted: formatBytes(backup.size || 0)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming('/api/backups', true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(backupsWithSize));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to list backups: ${err.message}`);
|
||||||
|
trackRequest('/api/backups', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/backups/create - Create manual backup
|
||||||
|
if (method === 'POST' && urlPath === '/api/backups/create') {
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
// Cleanup before creating backup
|
||||||
|
await cleanupOldBackups();
|
||||||
|
const backupPath = await createBackup();
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming('/api/backups/create', true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, path: backupPath }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to create backup: ${err.message}`);
|
||||||
|
trackRequest('/api/backups/create', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/backups/restore - Restore from backup
|
||||||
|
if (method === 'POST' && urlPath === '/api/backups/restore') {
|
||||||
|
try {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of req) {
|
||||||
|
body += chunk.toString();
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const { backupName } = data;
|
||||||
|
|
||||||
|
if (!backupName) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'backupName is required' }));
|
||||||
|
trackRequest('/api/backups/restore', false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
await restoreBackup(backupName);
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming('/api/backups/restore', true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Backup restored successfully' }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to restore backup: ${err.message}`);
|
||||||
|
trackRequest('/api/backups/restore', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/backups/:id - Delete backup
|
||||||
|
if (method === 'DELETE' && urlPath.startsWith('/api/backups/')) {
|
||||||
|
try {
|
||||||
|
const backupName = urlPath.split('/api/backups/')[1];
|
||||||
|
if (!backupName) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Backup name is required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKUP_DIR = process.env.BACKUP_DIR || './backups';
|
||||||
|
const backupPath = path.join(BACKUP_DIR, backupName);
|
||||||
|
|
||||||
|
// Verify it's a backup (directory or tar.gz)
|
||||||
|
if (!backupName.startsWith('backup-')) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Invalid backup name' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
// Check if it's a file (tar.gz) or directory
|
||||||
|
const stats = await fs.stat(backupPath).catch(() => null);
|
||||||
|
if (stats) {
|
||||||
|
if (stats.isFile()) {
|
||||||
|
// Delete tar.gz file
|
||||||
|
await fs.unlink(backupPath);
|
||||||
|
} else {
|
||||||
|
// Delete directory
|
||||||
|
await fs.rm(backupPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming(urlPath, true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Backup deleted successfully' }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to delete backup: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/backups/:id/metadata - Get backup metadata
|
||||||
|
if (method === 'GET' && urlPath.startsWith('/api/backups/') && urlPath.endsWith('/metadata')) {
|
||||||
|
try {
|
||||||
|
const backupName = urlPath.split('/api/backups/')[1].replace('/metadata', '');
|
||||||
|
if (!backupName) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Backup name is required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKUP_DIR = process.env.BACKUP_DIR || './backups';
|
||||||
|
const backupPath = path.join(BACKUP_DIR, backupName);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let metadata, restoreSource, extractDir = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if it's a tar.gz file
|
||||||
|
const stats = await fs.stat(backupPath);
|
||||||
|
const isTarGz = backupName.endsWith('.tar.gz') && stats.isFile();
|
||||||
|
|
||||||
|
if (isTarGz) {
|
||||||
|
// Extract tar.gz to read metadata
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
extractDir = path.join(BACKUP_DIR, `extract-metadata-${Date.now()}`);
|
||||||
|
await fs.mkdir(extractDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execAsync(`tar -xzf "${backupPath}" -C "${extractDir}"`);
|
||||||
|
const entries = await fs.readdir(extractDir, { withFileTypes: true });
|
||||||
|
if (entries.length === 1 && entries[0].isDirectory()) {
|
||||||
|
restoreSource = path.join(extractDir, entries[0].name);
|
||||||
|
} else {
|
||||||
|
restoreSource = extractDir;
|
||||||
|
}
|
||||||
|
const metadataPath = path.join(restoreSource, 'metadata.json');
|
||||||
|
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
||||||
|
} finally {
|
||||||
|
// Clean up extraction
|
||||||
|
if (extractDir) {
|
||||||
|
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Directory format
|
||||||
|
restoreSource = backupPath;
|
||||||
|
const metadataPath = path.join(backupPath, 'metadata.json');
|
||||||
|
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file sizes (only for directory format, tar.gz files are already compressed)
|
||||||
|
const filesWithSize = await Promise.all(
|
||||||
|
(metadata.files || []).map(async (fileName) => {
|
||||||
|
if (isTarGz) {
|
||||||
|
// For tar.gz, we can't easily get individual file sizes without extracting
|
||||||
|
return {
|
||||||
|
name: fileName,
|
||||||
|
size: 0,
|
||||||
|
sizeFormatted: 'N/A (compressed)',
|
||||||
|
modified: null
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const filePath = path.join(restoreSource, 'cache', fileName);
|
||||||
|
const altPath = path.join(restoreSource, fileName);
|
||||||
|
try {
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = await fs.stat(filePath);
|
||||||
|
} catch {
|
||||||
|
stats = await fs.stat(altPath);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: fileName,
|
||||||
|
size: stats.size,
|
||||||
|
sizeFormatted: formatBytes(stats.size),
|
||||||
|
modified: stats.mtime.toISOString()
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
name: fileName,
|
||||||
|
size: 0,
|
||||||
|
sizeFormatted: '0 B',
|
||||||
|
modified: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming(urlPath, true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
...metadata,
|
||||||
|
files: filesWithSize
|
||||||
|
}));
|
||||||
|
} catch (readErr) {
|
||||||
|
if (extractDir) {
|
||||||
|
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
throw readErr;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Backup not found' }));
|
||||||
|
} else {
|
||||||
|
logError('Admin', `Failed to get backup metadata: ${err.message}`);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleBackupsRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const pathModule = require('path');
|
||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const ca = require('../../../security/certificate_authority');
|
||||||
|
const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces');
|
||||||
|
const { logDebug, logError } = require('../../../infrastructure/logger');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
const certsDir = process.env.CERTS_DIR || './certs';
|
||||||
|
|
||||||
|
async function handleCertsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/certs') {
|
||||||
|
try {
|
||||||
|
const certDomains = await fs.readdir(certsDir);
|
||||||
|
const filteredDomains = [];
|
||||||
|
for (const file of certDomains) {
|
||||||
|
if ((await fs.stat(pathModule.join(certsDir, file))).isDirectory()) {
|
||||||
|
filteredDomains.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(filteredDomains));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch certs: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch certs' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath.startsWith('/api/cert-details')) {
|
||||||
|
const domain = url.searchParams.get('domain');
|
||||||
|
try {
|
||||||
|
const certPath = pathModule.join(certsDir, domain, 'cert.pem');
|
||||||
|
const certContent = await fs.readFile(certPath, 'utf8');
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end(certContent);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch cert details: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end('Failed to fetch cert details');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/regenerate-ca') {
|
||||||
|
try {
|
||||||
|
ca.regenerateRootCA();
|
||||||
|
broadcast({ type: 'update-certs' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to regenerate CA: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/install-ca') {
|
||||||
|
try {
|
||||||
|
ca.installRootCA();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to install CA: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/generate-cert') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!state.domainToIPMap.has(data.domain)) {
|
||||||
|
await createInterfaceForDomain(data.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = state.domainToIPMap.get(data.domain);
|
||||||
|
ca.getOrCreateDomainCert(data.domain, ip);
|
||||||
|
|
||||||
|
broadcast({ type: 'update-certs' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to generate cert: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/delete-cert') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const domainDir = pathModule.join(certsDir, data.domain);
|
||||||
|
await fs.rm(domainDir, { recursive: true, force: true });
|
||||||
|
broadcast({ type: 'update-certs' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to delete cert: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/regenerate-cert') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const domainDir = pathModule.join(certsDir, data.domain);
|
||||||
|
await fs.rm(domainDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
if (!state.domainToIPMap.has(data.domain)) {
|
||||||
|
await createInterfaceForDomain(data.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = state.domainToIPMap.get(data.domain);
|
||||||
|
ca.getOrCreateDomainCert(data.domain, ip);
|
||||||
|
|
||||||
|
broadcast({ type: 'update-certs' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to regenerate cert: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleCertsRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const { getConsensusState, getConsensusMetrics, doAutoVotes, invalidateEntriesCache } = require('../../../core/core');
|
||||||
|
const { logDebug, logError, logInfo } = require('../../../infrastructure/logger');
|
||||||
|
const { trackRequest } = require('../../../maintenance/metrics');
|
||||||
|
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
async function handleConsensusRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||||
|
|
||||||
|
// GET /api/consensus/metrics - Get consensus metrics
|
||||||
|
// Check this BEFORE the domain route to avoid matching "metrics" as a domain
|
||||||
|
if (method === 'GET' && urlPath === '/api/consensus/metrics') {
|
||||||
|
try {
|
||||||
|
trackRequest('/api/consensus/metrics', true);
|
||||||
|
logDebug('Consensus', 'Getting consensus metrics');
|
||||||
|
|
||||||
|
const metrics = getConsensusMetrics();
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(metrics));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logError('Consensus', `Failed to get consensus metrics: ${err.message}`);
|
||||||
|
trackRequest('/api/consensus/metrics', false);
|
||||||
|
createErrorResponse(res, 500, 'Failed to get consensus metrics', err.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/consensus/:domain - Get consensus state for a domain
|
||||||
|
const domainMatch = urlPath.match(/^\/api\/consensus\/([^\/]+)$/);
|
||||||
|
if (method === 'GET' && domainMatch) {
|
||||||
|
try {
|
||||||
|
trackRequest('/api/consensus/:domain', true);
|
||||||
|
const domain = decodeURIComponent(domainMatch[1]);
|
||||||
|
logDebug('Consensus', `Getting consensus state for domain: ${domain}`);
|
||||||
|
|
||||||
|
const consensusState = await getConsensusState(domain);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(consensusState));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logError('Consensus', `Failed to get consensus state: ${err.message}`);
|
||||||
|
trackRequest('/api/consensus/:domain', false);
|
||||||
|
createErrorResponse(res, 500, 'Failed to get consensus state', err.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/consensus/recalculate - Force consensus recalculation
|
||||||
|
if (method === 'POST' && urlPath === '/api/consensus/recalculate') {
|
||||||
|
try {
|
||||||
|
trackRequest('/api/consensus/recalculate', true);
|
||||||
|
logInfo('Consensus', 'Forcing consensus recalculation');
|
||||||
|
|
||||||
|
// Invalidate caches
|
||||||
|
invalidateEntriesCache();
|
||||||
|
|
||||||
|
// Trigger auto-votes
|
||||||
|
await doAutoVotes();
|
||||||
|
|
||||||
|
// Broadcast update
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
broadcast({ type: 'update-stats' });
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: 'Consensus recalculation triggered' }));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logError('Consensus', `Failed to recalculate consensus: ${err.message}`);
|
||||||
|
trackRequest('/api/consensus/recalculate', false);
|
||||||
|
createErrorResponse(res, 500, 'Failed to recalculate consensus', err.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/consensus/recalculate/:domain - Force consensus recalculation for specific domain
|
||||||
|
const recalcDomainMatch = urlPath.match(/^\/api\/consensus\/recalculate\/([^\/]+)$/);
|
||||||
|
if (method === 'POST' && recalcDomainMatch) {
|
||||||
|
try {
|
||||||
|
trackRequest('/api/consensus/recalculate/:domain', true);
|
||||||
|
const domain = decodeURIComponent(recalcDomainMatch[1]);
|
||||||
|
logInfo('Consensus', `Forcing consensus recalculation for domain: ${domain}`);
|
||||||
|
|
||||||
|
// Invalidate caches for this domain
|
||||||
|
invalidateEntriesCache();
|
||||||
|
|
||||||
|
// Get all entries and trigger auto-vote for this domain
|
||||||
|
const { getAllEntries, autoVoteForDomain } = require('../../../core/core');
|
||||||
|
const allEntries = await getAllEntries();
|
||||||
|
await autoVoteForDomain(domain, allEntries);
|
||||||
|
|
||||||
|
// Broadcast update
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
broadcast({ type: 'update-stats' });
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: `Consensus recalculation triggered for ${domain}` }));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logError('Consensus', `Failed to recalculate consensus for domain: ${err.message}`);
|
||||||
|
trackRequest('/api/consensus/recalculate/:domain', false);
|
||||||
|
createErrorResponse(res, 500, 'Failed to recalculate consensus for domain', err.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route not handled by consensus routes
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleConsensusRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
const dns = require('dns').promises;
|
||||||
|
const { exec, spawn } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const net = require('net');
|
||||||
|
const os = require('os');
|
||||||
|
const { logError, logDebug } = require('../../../infrastructure/logger');
|
||||||
|
const { trackRequest, trackRequestWithTiming } = require('../../../maintenance/metrics');
|
||||||
|
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
async function handleDiagnosticsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
// POST /api/diagnostics/dns-lookup
|
||||||
|
if (method === 'POST' && urlPath === '/api/diagnostics/dns-lookup') {
|
||||||
|
try {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of req) {
|
||||||
|
body += chunk.toString();
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const { domain, type = 'A' } = data;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'domain is required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let results = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type.toUpperCase()) {
|
||||||
|
case 'A':
|
||||||
|
results = await dns.resolve4(domain);
|
||||||
|
break;
|
||||||
|
case 'AAAA':
|
||||||
|
results = await dns.resolve6(domain);
|
||||||
|
break;
|
||||||
|
case 'MX':
|
||||||
|
results = await dns.resolveMx(domain);
|
||||||
|
break;
|
||||||
|
case 'TXT':
|
||||||
|
results = await dns.resolveTxt(domain);
|
||||||
|
break;
|
||||||
|
case 'NS':
|
||||||
|
results = await dns.resolveNs(domain);
|
||||||
|
break;
|
||||||
|
case 'CNAME':
|
||||||
|
results = await dns.resolveCname(domain);
|
||||||
|
break;
|
||||||
|
case 'SRV':
|
||||||
|
results = await dns.resolveSrv(domain);
|
||||||
|
break;
|
||||||
|
case 'PTR':
|
||||||
|
results = await dns.resolvePtr(domain);
|
||||||
|
break;
|
||||||
|
case 'SOA':
|
||||||
|
results = await dns.resolveSoa(domain);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported DNS record type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming(urlPath, true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
domain,
|
||||||
|
type,
|
||||||
|
results: Array.isArray(results) ? results : [results],
|
||||||
|
responseTime
|
||||||
|
}));
|
||||||
|
} catch (dnsErr) {
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
domain,
|
||||||
|
type,
|
||||||
|
error: dnsErr.message,
|
||||||
|
results: [],
|
||||||
|
responseTime
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `DNS lookup failed: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/diagnostics/ping
|
||||||
|
if (method === 'POST' && urlPath === '/api/diagnostics/ping') {
|
||||||
|
try {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of req) {
|
||||||
|
body += chunk.toString();
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const { target, count = 4, stream = false } = data;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'target is required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming mode
|
||||||
|
if (stream) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const platform = os.platform();
|
||||||
|
const pingArgs = platform === 'win32'
|
||||||
|
? ['-n', count.toString(), target]
|
||||||
|
: ['-c', count.toString(), target];
|
||||||
|
const pingProcess = spawn('ping', pingArgs);
|
||||||
|
|
||||||
|
// Set up streaming response
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/x-ndjson',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
pingProcess.stdout.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
output += text;
|
||||||
|
// Send each line as it arrives
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'output',
|
||||||
|
data: line,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}) + '\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pingProcess.stderr.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
errorOutput += text;
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
data: text,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}) + '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
pingProcess.on('close', (code) => {
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
const success = code === 0;
|
||||||
|
trackRequestWithTiming(urlPath, success, responseTime);
|
||||||
|
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'complete',
|
||||||
|
success,
|
||||||
|
exitCode: code,
|
||||||
|
output,
|
||||||
|
error: errorOutput || null,
|
||||||
|
responseTime
|
||||||
|
}) + '\n');
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
pingProcess.on('error', (err) => {
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: err.message,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}) + '\n');
|
||||||
|
res.end();
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-streaming mode (backward compatibility)
|
||||||
|
const startTime = Date.now();
|
||||||
|
const platform = os.platform();
|
||||||
|
const pingCmd = platform === 'win32'
|
||||||
|
? `ping -n ${count} ${target}`
|
||||||
|
: `ping -c ${count} ${target}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync(pingCmd, { timeout: 30000 });
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming(urlPath, true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
target,
|
||||||
|
count,
|
||||||
|
output: stdout,
|
||||||
|
error: stderr || null,
|
||||||
|
responseTime
|
||||||
|
}));
|
||||||
|
} catch (execErr) {
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
target,
|
||||||
|
count,
|
||||||
|
output: execErr.stdout || '',
|
||||||
|
error: execErr.stderr || execErr.message,
|
||||||
|
responseTime
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Ping failed: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/diagnostics/traceroute
|
||||||
|
if (method === 'POST' && urlPath === '/api/diagnostics/traceroute') {
|
||||||
|
try {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of req) {
|
||||||
|
body += chunk.toString();
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const { target, stream = false } = data;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'target is required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming mode
|
||||||
|
if (stream) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const platform = os.platform();
|
||||||
|
const tracerouteCmd = platform === 'win32' ? 'tracert' : 'traceroute';
|
||||||
|
const tracerouteArgs = platform === 'win32' ? [target] : [target];
|
||||||
|
const tracerouteProcess = spawn(tracerouteCmd, tracerouteArgs);
|
||||||
|
|
||||||
|
// Set up streaming response
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/x-ndjson',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
|
tracerouteProcess.stdout.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
output += text;
|
||||||
|
// Send each line as it arrives
|
||||||
|
const lines = text.split('\n').filter(line => line.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'output',
|
||||||
|
data: line,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}) + '\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracerouteProcess.stderr.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
errorOutput += text;
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
data: text,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}) + '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
tracerouteProcess.on('close', (code) => {
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
const success = code === 0;
|
||||||
|
trackRequestWithTiming(urlPath, success, responseTime);
|
||||||
|
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'complete',
|
||||||
|
success,
|
||||||
|
exitCode: code,
|
||||||
|
output,
|
||||||
|
error: errorOutput || null,
|
||||||
|
responseTime
|
||||||
|
}) + '\n');
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
tracerouteProcess.on('error', (err) => {
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: err.message,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}) + '\n');
|
||||||
|
res.end();
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-streaming mode (backward compatibility)
|
||||||
|
const startTime = Date.now();
|
||||||
|
const platform = os.platform();
|
||||||
|
const tracerouteCmd = platform === 'win32'
|
||||||
|
? `tracert ${target}`
|
||||||
|
: `traceroute ${target}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync(tracerouteCmd, { timeout: 60000 });
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming(urlPath, true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
target,
|
||||||
|
output: stdout,
|
||||||
|
error: stderr || null,
|
||||||
|
responseTime
|
||||||
|
}));
|
||||||
|
} catch (execErr) {
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
target,
|
||||||
|
output: execErr.stdout || '',
|
||||||
|
error: execErr.stderr || execErr.message,
|
||||||
|
responseTime
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Traceroute failed: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/diagnostics/connection-test
|
||||||
|
if (method === 'POST' && urlPath === '/api/diagnostics/connection-test') {
|
||||||
|
try {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of req) {
|
||||||
|
body += chunk.toString();
|
||||||
|
}
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const { domain, port } = data;
|
||||||
|
|
||||||
|
if (!domain || !port) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'domain and port are required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// First resolve domain to IP
|
||||||
|
let ip;
|
||||||
|
try {
|
||||||
|
const addresses = await dns.resolve4(domain);
|
||||||
|
ip = addresses[0];
|
||||||
|
} catch (dnsErr) {
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
domain,
|
||||||
|
port,
|
||||||
|
error: `DNS resolution failed: ${dnsErr.message}`,
|
||||||
|
responseTime: Date.now() - startTime
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test TCP connection
|
||||||
|
const testConnection = () => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
const timeout = 5000;
|
||||||
|
let connected = false;
|
||||||
|
|
||||||
|
socket.setTimeout(timeout);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
connected = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ success: true, latency: Date.now() - startTime });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ success: false, error: 'Connection timeout' });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
resolve({ success: false, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.connect(port, ip);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await testConnection();
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
trackRequestWithTiming(urlPath, result.success, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
...result,
|
||||||
|
domain,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
responseTime
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Connection test failed: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/diagnostics/bandwidth
|
||||||
|
if (method === 'GET' && urlPath === '/api/diagnostics/bandwidth') {
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const networkInterfaces = os.networkInterfaces();
|
||||||
|
const stats = {};
|
||||||
|
|
||||||
|
for (const [name, addresses] of Object.entries(networkInterfaces)) {
|
||||||
|
if (!addresses) continue;
|
||||||
|
let totalBytes = 0;
|
||||||
|
let totalPackets = 0;
|
||||||
|
|
||||||
|
for (const addr of addresses) {
|
||||||
|
if (addr.family === 'IPv4' || addr.family === 'IPv6') {
|
||||||
|
// Note: Node.js doesn't provide real-time bandwidth stats
|
||||||
|
// This is a placeholder structure
|
||||||
|
stats[name] = {
|
||||||
|
name,
|
||||||
|
addresses: addresses.map(a => ({
|
||||||
|
address: a.address,
|
||||||
|
netmask: a.netmask,
|
||||||
|
family: a.family,
|
||||||
|
mac: a.mac || 'N/A',
|
||||||
|
internal: a.internal
|
||||||
|
})),
|
||||||
|
// These would need system-specific tools to get real values
|
||||||
|
bytesReceived: 0,
|
||||||
|
bytesSent: 0,
|
||||||
|
packetsReceived: 0,
|
||||||
|
packetsSent: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming(urlPath, true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
interfaces: stats,
|
||||||
|
note: 'Bandwidth statistics require system-specific tools. Interface information only.',
|
||||||
|
responseTime
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Bandwidth stats failed: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleDiagnosticsRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const { getAllEntries, getHashForDomain, doAutoVotes, getConsensusState, invalidateEntriesCache } = require('../../../core/core');
|
||||||
|
const { addDomain } = require('../../../core/domains');
|
||||||
|
const { validateDomainAddition, validateDomainRemoval } = require('../../../infrastructure/validation');
|
||||||
|
const { atomicDomainCleanup } = require('../../../core/domain_cleanup');
|
||||||
|
const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces');
|
||||||
|
const { logDebug, logError, logInfo } = require('../../../infrastructure/logger');
|
||||||
|
const { trackRequest } = require('../../../maintenance/metrics');
|
||||||
|
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
const { getPersistentPublicKey } = require('../../../infrastructure/utils');
|
||||||
|
|
||||||
|
const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json';
|
||||||
|
|
||||||
|
async function handleDomainsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/resolved-domains') {
|
||||||
|
try {
|
||||||
|
const allEntries = await getAllEntries();
|
||||||
|
const domainClaimants = new Map();
|
||||||
|
for (const entry of allEntries) {
|
||||||
|
if (entry.key.startsWith('claim:')) {
|
||||||
|
const parts = entry.key.split(':');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const domain = parts[1];
|
||||||
|
const claimant = parts[2];
|
||||||
|
if (!domainClaimants.has(domain)) domainClaimants.set(domain, new Set());
|
||||||
|
domainClaimants.get(domain).add(claimant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const localWriter = getPersistentPublicKey();
|
||||||
|
const domains = new Set(domainClaimants.keys());
|
||||||
|
const resolved = [];
|
||||||
|
for (const domain of domains) {
|
||||||
|
const hash = await getHashForDomain(domain) || 'none';
|
||||||
|
const isLocal = localWriter ? domainClaimants.get(domain)?.has(localWriter) || false : false;
|
||||||
|
// Check if local writer is the resolved claimant (owner)
|
||||||
|
let isOwner = false;
|
||||||
|
try {
|
||||||
|
const consensusState = await getConsensusState(domain);
|
||||||
|
isOwner = localWriter ? consensusState.resolvedClaimant === localWriter : false;
|
||||||
|
} catch (err) {
|
||||||
|
logDebug('Admin', `Error checking ownership for ${domain}: ${err.message}`);
|
||||||
|
}
|
||||||
|
resolved.push({ domain, hash, isLocal, isOwner });
|
||||||
|
}
|
||||||
|
let internalDomains = ['p2ns.admin'];
|
||||||
|
try {
|
||||||
|
const { getInternalDomains } = require('../../../plugins/plugin-handler');
|
||||||
|
internalDomains = await getInternalDomains();
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback if plugin system not available
|
||||||
|
}
|
||||||
|
for (const d of internalDomains) {
|
||||||
|
resolved.push({ domain: d, hash: 'internal', isLocal: true, isOwner: true });
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(resolved.sort((a, b) => a.domain.localeCompare(b.domain))));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch resolved domains: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch domains' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/add-domain') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const validation = validateDomainAddition(data);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end(validation.error || 'Invalid input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract SSL flag - handle both boolean true and string "true"
|
||||||
|
const ssl = data.ssl === true || data.ssl === 'true' || data.ssl === 1;
|
||||||
|
await addDomain(validation.domain, validation.hash, ssl);
|
||||||
|
await doAutoVotes();
|
||||||
|
let domains = [];
|
||||||
|
if (await fs.access(domainsFile).then(() => true).catch(() => false)) {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8'));
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
logError('Admin', `Domains file does not contain an array, resetting to empty array`);
|
||||||
|
domains = [];
|
||||||
|
} else {
|
||||||
|
domains = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existingIndex = domains.findIndex(d => d.domain === validation.domain);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
domains[existingIndex].hash = validation.hash;
|
||||||
|
domains[existingIndex].ssl = ssl; // Update SSL flag
|
||||||
|
} else {
|
||||||
|
domains.push({ domain: validation.domain, hash: validation.hash, ssl: ssl });
|
||||||
|
}
|
||||||
|
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
|
||||||
|
if (!state.domainToIPMap.has(validation.domain)) {
|
||||||
|
await createInterfaceForDomain(validation.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${validation.domain}: ${state.domainToIPMap.get(validation.domain)}`);
|
||||||
|
}
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
trackRequest('/api/add-domain', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/remove-domain') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const validation = validateDomainRemoval(data);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end(validation.error || 'Invalid input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const domain = validation.domain;
|
||||||
|
|
||||||
|
// Authorization check: verify peer has claim and is resolved claimant
|
||||||
|
const localWriter = getPersistentPublicKey();
|
||||||
|
if (!localWriter) {
|
||||||
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Peer not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if peer has a claim for this domain
|
||||||
|
const allEntries = await getAllEntries();
|
||||||
|
const claimKey = `claim:${domain}:${localWriter}`;
|
||||||
|
const hasClaim = allEntries.some(entry => entry.key === claimKey);
|
||||||
|
|
||||||
|
if (!hasClaim) {
|
||||||
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Only domains you have claims and resolutions for can be deleted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if peer is the resolved claimant
|
||||||
|
const consensusState = await getConsensusState(domain);
|
||||||
|
if (consensusState.resolvedClaimant !== localWriter) {
|
||||||
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Only domains you have claims and resolutions for can be deleted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await atomicDomainCleanup(domain);
|
||||||
|
if (state.sendRemovalRequest) {
|
||||||
|
state.sendRemovalRequest(domain);
|
||||||
|
}
|
||||||
|
trackRequest('/api/remove-domain', true);
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
broadcast({ type: 'update-local-dns' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
trackRequest('/api/remove-domain', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/reset-claims-and-entries - Reset all claims and entries, request peers to provide domains
|
||||||
|
if (method === 'POST' && urlPath === '/api/reset-claims-and-entries') {
|
||||||
|
try {
|
||||||
|
trackRequest('/api/reset-claims-and-entries', true);
|
||||||
|
logInfo('Admin', 'Resetting all claims and entries, requesting peers to provide domains');
|
||||||
|
|
||||||
|
const pass = state.dnsPass;
|
||||||
|
if (!pass) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'DNS pass not initialized' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all entries
|
||||||
|
const allEntries = await getAllEntries(pass, false); // Don't use cache
|
||||||
|
|
||||||
|
// Filter for claims and votes
|
||||||
|
const entriesToRemove = allEntries.filter(entry =>
|
||||||
|
entry.key.startsWith('claim:') || entry.key.startsWith('vote:')
|
||||||
|
);
|
||||||
|
|
||||||
|
logInfo('Admin', `Removing ${entriesToRemove.length} claim and vote entries`);
|
||||||
|
|
||||||
|
// Remove all claim and vote entries
|
||||||
|
let removedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
for (const entry of entriesToRemove) {
|
||||||
|
try {
|
||||||
|
await pass.remove(entry.key);
|
||||||
|
removedCount++;
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error removing entry ${entry.key}: ${err.message}`);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
invalidateEntriesCache();
|
||||||
|
|
||||||
|
// Send message to all peers requesting them to provide their domains
|
||||||
|
let peersNotified = 0;
|
||||||
|
if (state.peerChannels && state.peerChannels.size > 0) {
|
||||||
|
for (const channels of state.peerChannels.values()) {
|
||||||
|
try {
|
||||||
|
if (channels.requestMessage && channels.requestChannel && !channels.requestChannel.destroyed) {
|
||||||
|
channels.requestMessage.send('request_domains');
|
||||||
|
peersNotified++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error sending request_domains message to peer: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logInfo('Admin', `Sent request_domains message to ${peersNotified} peer(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast updates
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
broadcast({ type: 'update-stats' });
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Claims and entries reset successfully',
|
||||||
|
removed: removedCount,
|
||||||
|
errors: errorCount,
|
||||||
|
peersNotified: peersNotified
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to reset claims and entries: ${err.message}`);
|
||||||
|
trackRequest('/api/reset-claims-and-entries', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleDomainsRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const { getAllEntries, removeAllRecords } = require('../../../core/core');
|
||||||
|
const { logError, logInfo } = require('../../../infrastructure/logger');
|
||||||
|
const { trackRequest } = require('../../../maintenance/metrics');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
async function handleEntriesRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/entries') {
|
||||||
|
try {
|
||||||
|
const entries = await getAllEntries();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(entries));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch entries: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch entries' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/remove-all-records') {
|
||||||
|
try {
|
||||||
|
const result = await removeAllRecords();
|
||||||
|
trackRequest('/api/remove-all-records', true);
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `Removed ${result.removed} records from the network`,
|
||||||
|
removed: result.removed,
|
||||||
|
errors: result.errors
|
||||||
|
}));
|
||||||
|
logInfo('Admin', `Removed all records: ${result.removed} removed, ${result.errors} errors`);
|
||||||
|
} catch (err) {
|
||||||
|
trackRequest('/api/remove-all-records', false);
|
||||||
|
logError('Admin', `Failed to remove all records: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to remove all records', message: err.message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleEntriesRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const dgram = require('dgram');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const { addDomain } = require('../../../core/domains');
|
||||||
|
const { validateHolesailClient } = require('../../../infrastructure/validation');
|
||||||
|
const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces');
|
||||||
|
const { logDebug, logError, logInfo, logWarn } = require('../../../infrastructure/logger');
|
||||||
|
const { startHolesailServer, saveHolesailServers } = require('../holesail-servers');
|
||||||
|
const { startForkedHolesailClient, saveHolesailClients } = require('../holesail-clients');
|
||||||
|
const { ensurePortFree } = require('../port-management');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
const { getConsensusState, getClaimClients, updateClaimClients } = require('../../../core/core');
|
||||||
|
const { getPersistentPublicKey } = require('../../../infrastructure/utils');
|
||||||
|
|
||||||
|
const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json';
|
||||||
|
const subscriptionsFile = process.env.SUBSCRIPTIONS_FILE || './cache/subscriptions.json';
|
||||||
|
const subscriptionManager = require('../../subscription-manager');
|
||||||
|
|
||||||
|
async function handleHolesailRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/holesail-servers') {
|
||||||
|
try {
|
||||||
|
const servers = Array.from(state.holesailOpts.entries()).map(([id, opts]) => {
|
||||||
|
const child = state.holesailChildren.get(id);
|
||||||
|
const info = state.holesailInfos.get(id) || {};
|
||||||
|
const status = child && !child.killed ? 'running' : 'stopped';
|
||||||
|
return { id, opts, info: { ...info, state: status } };
|
||||||
|
});
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(servers));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch Holesail servers: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch Holesail servers' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/holesail-clients') {
|
||||||
|
try {
|
||||||
|
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
||||||
|
const child = state.holesailClientChildren.get(id);
|
||||||
|
const info = state.holesailClientInfos.get(id) || {};
|
||||||
|
const key = `${opts.domain}:${opts.port}`;
|
||||||
|
const isHolesailActive = state.holesails.has(key);
|
||||||
|
const isChildRunning = child && !child.killed;
|
||||||
|
let status = 'stopped';
|
||||||
|
if (isChildRunning && isHolesailActive && info.state !== 'error') {
|
||||||
|
status = 'running';
|
||||||
|
} else if (isChildRunning || isHolesailActive) {
|
||||||
|
status = 'starting';
|
||||||
|
} else if (info.state === 'error') {
|
||||||
|
status = 'error';
|
||||||
|
}
|
||||||
|
return { id, opts, info: { ...info, state: status } };
|
||||||
|
});
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(clients));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch Holesail clients: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch Holesail clients' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-create') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const opts = { ...data };
|
||||||
|
const domain = opts.domain;
|
||||||
|
delete opts.domain;
|
||||||
|
const id = crypto.randomBytes(16).toString('hex');
|
||||||
|
logDebug('Admin', `Creating Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`);
|
||||||
|
const { id: createdId, info } = await startHolesailServer(id, opts);
|
||||||
|
if (domain) {
|
||||||
|
const hash = info.url;
|
||||||
|
await addDomain(domain, hash);
|
||||||
|
if (!state.domainToIPMap.has(domain)) {
|
||||||
|
await createInterfaceForDomain(domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${domain}: ${state.domainToIPMap.get(domain)}`);
|
||||||
|
}
|
||||||
|
let domains = [];
|
||||||
|
if (await fs.access(domainsFile).then(() => true).catch(() => false)) {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8'));
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
logError('Admin', `Domains file does not contain an array, resetting to empty array`);
|
||||||
|
domains = [];
|
||||||
|
} else {
|
||||||
|
domains = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existingIndex = domains.findIndex(d => d.domain === domain);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
domains[existingIndex].hash = hash;
|
||||||
|
} else {
|
||||||
|
domains.push({ domain, hash });
|
||||||
|
}
|
||||||
|
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
|
||||||
|
logInfo('Admin', `Automatically added domain ${domain} with hash ${hash} to P2P network and domains.json`);
|
||||||
|
}
|
||||||
|
await saveHolesailServers();
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ id: createdId }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to create Holesail server: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-delete') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { id } = JSON.parse(body);
|
||||||
|
const child = state.holesailChildren.get(id);
|
||||||
|
const opts = state.holesailOpts.get(id);
|
||||||
|
if (child) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await new Promise(resolve => {
|
||||||
|
child.on('exit', () => resolve());
|
||||||
|
setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
logWarn('Admin', `Forced SIGKILL for Holesail server child ${id}`);
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
state.holesailChildren.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
logInfo('Admin', `Closed Holesail server child process ${id}`);
|
||||||
|
}
|
||||||
|
state.holesailOpts.delete(id);
|
||||||
|
state.holesailInfos.delete(id);
|
||||||
|
await saveHolesailServers();
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to delete Holesail server: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-restart') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { id } = JSON.parse(body);
|
||||||
|
const child = state.holesailChildren.get(id);
|
||||||
|
const opts = state.holesailOpts.get(id);
|
||||||
|
if (!opts) {
|
||||||
|
throw new Error('Server not found');
|
||||||
|
}
|
||||||
|
let exitPromise;
|
||||||
|
if (child) {
|
||||||
|
logDebug('Admin', `Terminating existing Holesail server child process ${id}`);
|
||||||
|
exitPromise = new Promise((resolve) => {
|
||||||
|
child.once('exit', resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
logWarn('Admin', `Forced SIGKILL for Holesail server child ${id}`);
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await exitPromise;
|
||||||
|
logInfo('Admin', `Closed Holesail server child process ${id}`);
|
||||||
|
}
|
||||||
|
state.holesailInfos.delete(id);
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
logDebug('Admin', `Restarting Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`);
|
||||||
|
const { id: createdId, info } = await startHolesailServer(id, opts);
|
||||||
|
if (opts.domain) {
|
||||||
|
const hash = info.url.replace('hs://', '');
|
||||||
|
await addDomain(opts.domain, hash);
|
||||||
|
if (!state.domainToIPMap.has(opts.domain)) {
|
||||||
|
await createInterfaceForDomain(opts.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${opts.domain}: ${state.domainToIPMap.get(opts.domain)}`);
|
||||||
|
}
|
||||||
|
let domains = [];
|
||||||
|
if (await fs.access(domainsFile).then(() => true).catch(() => false)) {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8'));
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
logError('Admin', `Domains file does not contain an array, resetting to empty array`);
|
||||||
|
domains = [];
|
||||||
|
} else {
|
||||||
|
domains = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existingIndex = domains.findIndex(d => d.domain === opts.domain);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
domains[existingIndex].hash = hash;
|
||||||
|
} else {
|
||||||
|
domains.push({ domain: opts.domain, hash });
|
||||||
|
}
|
||||||
|
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
|
||||||
|
logInfo('Admin', `Automatically added domain ${opts.domain} with hash ${hash} to P2P network and domains.json`);
|
||||||
|
}
|
||||||
|
await saveHolesailServers();
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to restart Holesail server: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-client-create') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const validation = validateHolesailClient(data);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end(validation.error || 'Invalid input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { domain, serviceName, key, port, protocol } = { ...validation, serviceName: data.serviceName };
|
||||||
|
|
||||||
|
// Validate ownership
|
||||||
|
const consensusState = await getConsensusState(domain);
|
||||||
|
const localWriter = getPersistentPublicKey();
|
||||||
|
if (!localWriter || consensusState.resolvedClaimant !== localWriter) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end('You must own this domain to create a client');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.domainToIPMap.has(domain)) {
|
||||||
|
await createInterfaceForDomain(domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${domain}: ${state.domainToIPMap.get(domain)}`);
|
||||||
|
}
|
||||||
|
const ip = state.domainToIPMap.get(domain);
|
||||||
|
const portFree = await ensurePortFree(ip, port);
|
||||||
|
if (!portFree) {
|
||||||
|
throw new Error(`Unable to ensure port ${port} free on ${ip}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use domain_servicename format for client ID if serviceName provided
|
||||||
|
const id = serviceName ? `${domain}_${serviceName}`.replace(/[^a-zA-Z0-9_]/g, '_') : crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
// Check if client with this ID already exists
|
||||||
|
if (state.holesailClientOpts.has(id)) {
|
||||||
|
res.writeHead(409);
|
||||||
|
res.end('A client with this service name already exists for this domain');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.holesailClientInfos.set(id, { state: 'starting' });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
await startForkedHolesailClient(id, { domain, key, port, protocol: protocol || 'tcp' });
|
||||||
|
state.holesailClientInfos.set(id, { ...state.holesailClientInfos.get(id), state: 'running' });
|
||||||
|
await saveHolesailClients();
|
||||||
|
|
||||||
|
// Update claim record with new client
|
||||||
|
if (serviceName && localWriter) {
|
||||||
|
const existingClients = await getClaimClients(domain, localWriter);
|
||||||
|
const newClient = {
|
||||||
|
name: serviceName,
|
||||||
|
key: key,
|
||||||
|
port: port,
|
||||||
|
protocol: protocol || 'tcp'
|
||||||
|
};
|
||||||
|
// Remove existing client with same name if any
|
||||||
|
const updatedClients = existingClients.filter(c => c.name !== serviceName);
|
||||||
|
updatedClients.push(newClient);
|
||||||
|
await updateClaimClients(domain, localWriter, updatedClients);
|
||||||
|
logInfo('Admin', `Updated claim record for ${domain} with client ${serviceName}`);
|
||||||
|
|
||||||
|
// Trigger claim change check to auto-subscribe peers with subscribeAll enabled
|
||||||
|
try {
|
||||||
|
const { checkClaimChanges } = require('../../subscription-manager');
|
||||||
|
checkClaimChanges();
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Error triggering claim change check: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ id }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to create Holesail client: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-client-delete') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { id } = JSON.parse(body);
|
||||||
|
const child = state.holesailClientChildren.get(id);
|
||||||
|
const opts = state.holesailClientOpts.get(id);
|
||||||
|
if (child) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await new Promise(resolve => {
|
||||||
|
child.on('exit', () => resolve());
|
||||||
|
setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
logWarn('Admin', `Forced SIGKILL for Holesail client child ${id}`);
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
state.holesailClientChildren.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
logInfo('Admin', `Closed Holesail client ${id} for ${opts.domain}:${opts.port}`);
|
||||||
|
}
|
||||||
|
if (opts) {
|
||||||
|
const key = `${opts.domain}:${opts.port}`;
|
||||||
|
const isUDP = opts.protocol === 'udp';
|
||||||
|
const holesail = state.holesails.get(key);
|
||||||
|
|
||||||
|
if (holesail) {
|
||||||
|
if (holesail instanceof dgram.Socket || isUDP) {
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
if (holesail.close) {
|
||||||
|
holesail.close(() => {
|
||||||
|
logInfo('Admin', `Closed UDP Holesail connection for ${key}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`);
|
||||||
|
try {
|
||||||
|
if (holesail.close) holesail.close();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors on forced close
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Error closing UDP Holesail for ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await holesail.close();
|
||||||
|
logInfo('Admin', `Closed TCP Holesail connection for ${key}`);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Error closing TCP Holesail for ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.holesails.delete(key);
|
||||||
|
if (state.holesailStartTimes) {
|
||||||
|
state.holesailStartTimes.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only cleanup TLS/HTTP servers for TCP connections
|
||||||
|
if (!isUDP) {
|
||||||
|
const tlsServer = state.tlsServers.get(key);
|
||||||
|
if (tlsServer) {
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
tlsServer.close(resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing TLS server for ${key}, forcing closure`);
|
||||||
|
try {
|
||||||
|
if (tlsServer.destroy) tlsServer.destroy();
|
||||||
|
else if (tlsServer.close) tlsServer.close();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
state.tlsServers.delete(key);
|
||||||
|
logInfo('Admin', `Closed TLS server for ${key}`);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Error closing TLS server for ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const httpServer = state.httpServers.get(key);
|
||||||
|
if (httpServer) {
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
httpServer.close(resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing HTTP server for ${key}, forcing closure`);
|
||||||
|
try {
|
||||||
|
if (httpServer.destroy) httpServer.destroy();
|
||||||
|
else if (httpServer.close) httpServer.close();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
state.httpServers.delete(key);
|
||||||
|
logInfo('Admin', `Closed HTTP server for ${key}`);
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Error closing HTTP server for ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For UDP, don't call ensurePortFree as it may kill wrong processes
|
||||||
|
// UDP sockets don't hold ports the same way TCP does
|
||||||
|
// The child process termination should be sufficient
|
||||||
|
if (!isUDP) {
|
||||||
|
const ip = state.domainToIPMap.get(opts.domain);
|
||||||
|
if (ip && opts.port) {
|
||||||
|
try {
|
||||||
|
const freed = await ensurePortFree(ip, opts.port);
|
||||||
|
if (!freed) {
|
||||||
|
logWarn('Admin', `Port ${opts.port} on ${ip} may still be in use for ${key}`);
|
||||||
|
} else {
|
||||||
|
logInfo('Admin', `Successfully ensured port ${opts.port} free on ${ip} for ${key}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Error ensuring port free for ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logDebug('Admin', `Skipping port cleanup for UDP client ${key} - child process termination should be sufficient`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update claim record to remove client
|
||||||
|
if (opts && opts.domain) {
|
||||||
|
const consensusState = await getConsensusState(opts.domain);
|
||||||
|
const localWriter = getPersistentPublicKey();
|
||||||
|
if (localWriter && consensusState.resolvedClaimant === localWriter) {
|
||||||
|
const existingClients = await getClaimClients(opts.domain, localWriter);
|
||||||
|
// Try to find client by matching port and domain
|
||||||
|
const updatedClients = existingClients.filter(c => {
|
||||||
|
// If client ID matches domain_servicename format, extract service name
|
||||||
|
if (id.includes('_') && id.startsWith(opts.domain + '_')) {
|
||||||
|
const serviceName = id.substring(opts.domain.length + 1);
|
||||||
|
return c.name !== serviceName;
|
||||||
|
}
|
||||||
|
// Otherwise, match by port
|
||||||
|
return c.port !== opts.port;
|
||||||
|
});
|
||||||
|
await updateClaimClients(opts.domain, localWriter, updatedClients);
|
||||||
|
logInfo('Admin', `Updated claim record for ${opts.domain} after client deletion`);
|
||||||
|
|
||||||
|
// Trigger claim change check to auto-unsubscribe peers
|
||||||
|
try {
|
||||||
|
const { checkClaimChanges } = require('../../subscription-manager');
|
||||||
|
checkClaimChanges();
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Error triggering claim change check: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.holesailClientOpts.delete(id);
|
||||||
|
state.holesailClientInfos.delete(id);
|
||||||
|
await saveHolesailClients();
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to delete Holesail client: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/domain-services') {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||||
|
const domain = url.searchParams.get('domain');
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end(JSON.stringify({ error: 'Domain parameter required' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consensusState = await getConsensusState(domain);
|
||||||
|
if (!consensusState.resolvedClaimant) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify([]));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = await getClaimClients(domain, consensusState.resolvedClaimant);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(clients));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch domain services: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch domain services' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/service-subscriptions') {
|
||||||
|
try {
|
||||||
|
const subscriptions = await subscriptionManager.loadSubscriptions();
|
||||||
|
// Convert to flat list format for backward compatibility with UI
|
||||||
|
const flatList = [];
|
||||||
|
for (const domainSub of subscriptions) {
|
||||||
|
for (const service of domainSub.services) {
|
||||||
|
flatList.push({
|
||||||
|
domain: domainSub.domain,
|
||||||
|
serviceName: service.serviceName,
|
||||||
|
key: service.key,
|
||||||
|
port: service.port,
|
||||||
|
protocol: service.protocol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(flatList));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch subscriptions: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch subscriptions' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/subscribe-all-domains') {
|
||||||
|
try {
|
||||||
|
const domains = await subscriptionManager.getSubscribeAllDomains();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(domains));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch subscribe-all domains: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch subscribe-all domains' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/service-subscribe') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { domain, serviceName, key, port, protocol } = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!domain || !serviceName || !key || !port) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Missing required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await subscriptionManager.subscribeToService(domain, serviceName, key, port, protocol);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
res.writeHead(409);
|
||||||
|
res.end('Already subscribed to this service');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to subscribe to service: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/service-unsubscribe') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { domain, serviceName } = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!domain || !serviceName) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Missing required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await subscriptionManager.unsubscribeFromService(domain, serviceName);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Subscription not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to unsubscribe from service: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/subscribe-all') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { domain } = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Missing domain field');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscriptionManager.setSubscribeAll(domain, true);
|
||||||
|
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to set subscribe-all: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/unsubscribe-all') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { domain } = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Missing domain field');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscriptionManager.setSubscribeAll(domain, false);
|
||||||
|
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to clear subscribe-all: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-client-restart') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { id } = JSON.parse(body);
|
||||||
|
logDebug('Admin', `Initiating restart for Holesail client ${id}`);
|
||||||
|
const child = state.holesailClientChildren.get(id);
|
||||||
|
const opts = state.holesailClientOpts.get(id);
|
||||||
|
if (!opts) {
|
||||||
|
throw new Error(`Client ${id} not found`);
|
||||||
|
}
|
||||||
|
const key = `${opts.domain}:${opts.port}`;
|
||||||
|
let exitPromise;
|
||||||
|
if (child) {
|
||||||
|
logDebug('Admin', `Terminating existing child process for client ${id}`);
|
||||||
|
exitPromise = new Promise((resolve) => {
|
||||||
|
child.once('exit', resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
logWarn('Admin', `Forced SIGKILL for Holesail client child ${id}`);
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await exitPromise;
|
||||||
|
state.holesailClientChildren.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
logInfo('Admin', `Closed Holesail client child process ${id}`);
|
||||||
|
}
|
||||||
|
const holesail = state.holesails.get(key);
|
||||||
|
if (holesail) {
|
||||||
|
logDebug('Admin', `Closing Holesail connection for ${key}`);
|
||||||
|
if (holesail instanceof dgram.Socket) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
holesail.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
logWarn('Admin', `Error closing UDP Holesail for ${key}: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
logInfo('Admin', `Closed UDP Holesail connection for ${key}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`);
|
||||||
|
try {
|
||||||
|
holesail.close();
|
||||||
|
resolve();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await holesail.close();
|
||||||
|
logInfo('Admin', `Closed TCP Holesail connection for ${key}`);
|
||||||
|
}
|
||||||
|
state.holesails.delete(key);
|
||||||
|
if (state.holesailStartTimes) {
|
||||||
|
state.holesailStartTimes.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tlsServer = state.tlsServers.get(key);
|
||||||
|
if (tlsServer) {
|
||||||
|
logDebug('Admin', `Closing TLS server for ${key}`);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
tlsServer.close(resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing TLS server for ${key}, forcing closure`);
|
||||||
|
tlsServer.destroy ? tlsServer.destroy() : tlsServer.close();
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
state.tlsServers.delete(key);
|
||||||
|
logInfo('Admin', `Closed TLS server for ${key}`);
|
||||||
|
}
|
||||||
|
const httpServer = state.httpServers.get(key);
|
||||||
|
if (httpServer) {
|
||||||
|
logDebug('Admin', `Closing HTTP server for ${key}`);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
httpServer.close(resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing HTTP server for ${key}, forcing closure`);
|
||||||
|
httpServer.destroy ? httpServer.destroy() : httpServer.close();
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
state.httpServers.delete(key);
|
||||||
|
logInfo('Admin', `Closed HTTP server for ${key}`);
|
||||||
|
}
|
||||||
|
state.holesailClientInfos.set(id, { state: 'starting' });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
logInfo('Admin', `Holesail client ${id} stopped, preparing to restart`);
|
||||||
|
if (!state.domainToIPMap.has(opts.domain)) {
|
||||||
|
await createInterfaceForDomain(opts.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${opts.domain}: ${state.domainToIPMap.get(opts.domain)}`);
|
||||||
|
}
|
||||||
|
const ip = state.domainToIPMap.get(opts.domain);
|
||||||
|
const portFree = await ensurePortFree(ip, opts.port);
|
||||||
|
if (!portFree) {
|
||||||
|
state.holesailClientInfos.set(id, { state: 'error', error: `Unable to free port ${opts.port} on ${ip}` });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
throw new Error(`Unable to ensure port ${opts.port} free on ${ip}`);
|
||||||
|
}
|
||||||
|
await startForkedHolesailClient(id, opts);
|
||||||
|
logInfo('Admin', `Successfully restarted Holesail client ${id} for ${opts.domain}:${opts.port}`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
const isHolesailActive = state.holesails.has(key);
|
||||||
|
if (!isHolesailActive) {
|
||||||
|
logWarn('Admin', `Holesail client ${id} for ${key} started but not active in state.holesails. Attempting final restart.`);
|
||||||
|
state.holesailClientInfos.set(id, { state: 'starting' });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
await startForkedHolesailClient(id, opts);
|
||||||
|
}
|
||||||
|
const finalCheck = state.holesails.has(key);
|
||||||
|
if (!finalCheck) {
|
||||||
|
state.holesailClientInfos.set(id, { state: 'error', error: `Failed to start after final attempt` });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
throw new Error(`Holesail client ${id} for ${key} failed to start after final attempt`);
|
||||||
|
}
|
||||||
|
state.holesailClientInfos.set(id, { ...state.holesailClientInfos.get(id), state: 'running' });
|
||||||
|
logDebug('Admin', `Verified Holesail client ${id} is active for ${key}`);
|
||||||
|
await saveHolesailClients();
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
state.holesailClientInfos.set(id, { state: 'error', error: err.message });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
logError('Admin', `Failed to restart Holesail client: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleHolesailRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const { checkRateLimit } = require('../../../infrastructure/rate_limit');
|
||||||
|
const { trackRequest } = require('../../../maintenance/metrics');
|
||||||
|
const { handleStaticRoutes } = require('./static');
|
||||||
|
const { handleDomainsRoutes } = require('./domains');
|
||||||
|
const { handleEntriesRoutes } = require('./entries');
|
||||||
|
const { handlePeersRoutes } = require('./peers');
|
||||||
|
const { handleCertsRoutes } = require('./certs');
|
||||||
|
const { handleInterfacesRoutes } = require('./interfaces');
|
||||||
|
const { handleLocalDnsRoutes } = require('./local-dns');
|
||||||
|
const { handleStatusRoutes } = require('./status');
|
||||||
|
const { handleStatsRoutes } = require('./stats');
|
||||||
|
const { handleHolesailRoutes } = require('./holesail');
|
||||||
|
const { handleSettingsRoutes } = require('./settings');
|
||||||
|
const { handleBackupsRoutes } = require('./backups');
|
||||||
|
const { handleDiagnosticsRoutes } = require('./diagnostics');
|
||||||
|
const { handleConsensusRoutes } = require('./consensus');
|
||||||
|
const { handlePluginsRoutes } = require('./plugins');
|
||||||
|
|
||||||
|
async function handleAdminRequest(req, res) {
|
||||||
|
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||||
|
const urlPath = url.pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
// Check rate limit for API endpoints (GET requests and local IPs are exempt)
|
||||||
|
// Only rate limit POST requests, GET requests are safe and expected to be frequent
|
||||||
|
if (urlPath.startsWith('/api/') && method === 'POST') {
|
||||||
|
const rateLimitError = checkRateLimit(req);
|
||||||
|
if (rateLimitError) {
|
||||||
|
res.writeHead(rateLimitError.statusCode, rateLimitError.headers);
|
||||||
|
res.end(rateLimitError.body);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach urlPath to req for route handlers
|
||||||
|
req.urlPath = urlPath;
|
||||||
|
|
||||||
|
// Try each route handler in order
|
||||||
|
if (await handleStaticRoutes(req, res)) return;
|
||||||
|
if (await handleDomainsRoutes(req, res)) return;
|
||||||
|
if (await handleEntriesRoutes(req, res)) return;
|
||||||
|
if (await handlePeersRoutes(req, res)) return;
|
||||||
|
if (await handleCertsRoutes(req, res)) return;
|
||||||
|
if (await handleInterfacesRoutes(req, res)) return;
|
||||||
|
if (await handleLocalDnsRoutes(req, res)) return;
|
||||||
|
if (await handleStatusRoutes(req, res)) return;
|
||||||
|
if (await handleStatsRoutes(req, res)) return;
|
||||||
|
if (await handleHolesailRoutes(req, res)) return;
|
||||||
|
if (await handleSettingsRoutes(req, res)) return;
|
||||||
|
if (await handleBackupsRoutes(req, res)) return;
|
||||||
|
if (await handleDiagnosticsRoutes(req, res)) return;
|
||||||
|
if (await handleConsensusRoutes(req, res)) return;
|
||||||
|
if (await handlePluginsRoutes(req, res)) return;
|
||||||
|
|
||||||
|
// No route matched
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not Found');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleAdminRequest };
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const { cleanupInterfaces } = require('../../../maintenance/cleanup');
|
||||||
|
const { logError } = require('../../../infrastructure/logger');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
async function handleInterfacesRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/interfaces') {
|
||||||
|
try {
|
||||||
|
const interfaces = Array.from(state.domainToIPMap.entries()).map(([domain, ip]) => ({ domain, ip }));
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(interfaces));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch interfaces' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/cleanup-interfaces') {
|
||||||
|
try {
|
||||||
|
await cleanupInterfaces();
|
||||||
|
broadcast({ type: 'update-interfaces' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to cleanup interfaces: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleInterfacesRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const dns = require('dns').promises;
|
||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const { logError, logWarn, logInfo } = require('../../../infrastructure/logger');
|
||||||
|
const { saveSelectorCache } = require('../cache');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
const localDnsFile = process.env.LOCAL_DNS_FILE || 'cache/local_dns.json';
|
||||||
|
|
||||||
|
async function handleLocalDnsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/local-dns') {
|
||||||
|
try {
|
||||||
|
let records = state.localDnsRecords || [];
|
||||||
|
let conflicts = [];
|
||||||
|
const domains = new Set([...state.domainsWithBoth, ...state.versionPreferences.keys()]);
|
||||||
|
for (const domain of domains) {
|
||||||
|
let publicIP = state.publicIpForDomain[domain];
|
||||||
|
if (!publicIP && state.versionPreferences.has(domain)) {
|
||||||
|
try {
|
||||||
|
const ips = await dns.resolve4(domain);
|
||||||
|
publicIP = ips[0] || 'N/A';
|
||||||
|
state.publicIpForDomain[domain] = publicIP;
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Failed to resolve public IP for ${domain}: ${err.message}`);
|
||||||
|
publicIP = 'N/A';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conflicts.push({
|
||||||
|
domain,
|
||||||
|
version: state.versionPreferences.get(domain) || 'p2p',
|
||||||
|
publicIP: publicIP || 'N/A'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
records = records.map((rec, index) => ({ ...rec, index }));
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ records, conflicts }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch local DNS and conflicts: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch local DNS and conflicts' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/selector-cache') {
|
||||||
|
try {
|
||||||
|
const preferences = Object.fromEntries(state.versionPreferences);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(preferences));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch selector cache: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch selector cache' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/add-local-dns') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(body);
|
||||||
|
if (!record.name || !record.type || !record.ttl || isNaN(record.ttl)) {
|
||||||
|
throw new Error('Missing or invalid required fields: name, type, ttl');
|
||||||
|
}
|
||||||
|
record.class = record.class || 'IN';
|
||||||
|
let records = state.localDnsRecords || [];
|
||||||
|
records.push(record);
|
||||||
|
await fs.writeFile(localDnsFile, JSON.stringify(records, null, 2));
|
||||||
|
state.localDnsRecords = records;
|
||||||
|
broadcast({ type: 'update-local-dns' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to add local DNS record: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/update-local-dns') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { index, record } = JSON.parse(body);
|
||||||
|
if (!record.name || !record.type || !record.ttl || isNaN(record.ttl)) {
|
||||||
|
throw new Error('Missing or invalid required fields: name, type, ttl');
|
||||||
|
}
|
||||||
|
let records = state.localDnsRecords || [];
|
||||||
|
if (index >= 0 && index < records.length) {
|
||||||
|
record.class = record.class || 'IN';
|
||||||
|
records[index] = record;
|
||||||
|
await fs.writeFile(localDnsFile, JSON.stringify(records, null, 2));
|
||||||
|
state.localDnsRecords = records;
|
||||||
|
broadcast({ type: 'update-local-dns' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} else {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Invalid index');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to update local DNS record: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/delete-local-dns') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { index } = JSON.parse(body);
|
||||||
|
let records = state.localDnsRecords || [];
|
||||||
|
if (index >= 0 && index < records.length) {
|
||||||
|
records.splice(index, 1);
|
||||||
|
await fs.writeFile(localDnsFile, JSON.stringify(records, null, 2));
|
||||||
|
state.localDnsRecords = records;
|
||||||
|
broadcast({ type: 'update-local-dns' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} else {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Invalid index');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to delete local DNS record: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/update-version-preference') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { domain, version } = JSON.parse(body);
|
||||||
|
if (version !== 'p2p' && version !== 'public') {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Invalid version, must be "p2p" or "public"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.versionPreferences.set(domain, version);
|
||||||
|
await saveSelectorCache();
|
||||||
|
broadcast({ type: 'update-local-dns' });
|
||||||
|
logInfo('Admin', `Updated version preference for ${domain} to ${version}`);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to update version preference: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleLocalDnsRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const { logError } = require('../../../infrastructure/logger');
|
||||||
|
const { trackRequest, trackRequestWithTiming } = require('../../../maintenance/metrics');
|
||||||
|
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
||||||
|
const { saveBlockedPeers } = require('../cache');
|
||||||
|
|
||||||
|
async function handlePeersRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
// GET /api/peers - List all peers with details
|
||||||
|
if (method === 'GET' && urlPath === '/api/peers') {
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const peers = Array.from(state.connectedPeers).map(peerId => {
|
||||||
|
const connectTime = state.peerStartTimes.get(peerId);
|
||||||
|
const uptime = connectTime ? Date.now() - connectTime : 0;
|
||||||
|
const metrics = state.peerMetrics.get(peerId) || {
|
||||||
|
connections: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
avgDuration: 0,
|
||||||
|
lastSeen: null
|
||||||
|
};
|
||||||
|
const isBlocked = state.blockedPeers && state.blockedPeers.has(peerId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: peerId,
|
||||||
|
connected: true,
|
||||||
|
connectTime: connectTime || null,
|
||||||
|
uptime,
|
||||||
|
metrics,
|
||||||
|
isBlocked
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming('/api/peers', true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(peers));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch peers: ${err.message}`);
|
||||||
|
trackRequest('/api/peers', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/peers/:id - Get peer details
|
||||||
|
if (method === 'GET' && urlPath.startsWith('/api/peers/') && !urlPath.endsWith('/history') && !urlPath.endsWith('/blocked')) {
|
||||||
|
try {
|
||||||
|
const peerId = urlPath.split('/api/peers/')[1];
|
||||||
|
if (!peerId) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Peer ID is required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const connectTime = state.peerStartTimes.get(peerId);
|
||||||
|
const uptime = connectTime ? Date.now() - connectTime : 0;
|
||||||
|
const history = state.peerHistory.get(peerId) || [];
|
||||||
|
const metrics = state.peerMetrics.get(peerId) || {
|
||||||
|
connections: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
avgDuration: 0,
|
||||||
|
lastSeen: null
|
||||||
|
};
|
||||||
|
const isBlocked = state.blockedPeers && state.blockedPeers.has(peerId);
|
||||||
|
const isConnected = state.connectedPeers.has(peerId);
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming(urlPath, true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
id: peerId,
|
||||||
|
connected: isConnected,
|
||||||
|
connectTime: connectTime || null,
|
||||||
|
uptime,
|
||||||
|
history: history.slice(-50), // Last 50 events
|
||||||
|
metrics,
|
||||||
|
isBlocked
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch peer details: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/peers/:id/history - Get peer connection history
|
||||||
|
if (method === 'GET' && urlPath.endsWith('/history')) {
|
||||||
|
try {
|
||||||
|
const peerId = urlPath.split('/api/peers/')[1].replace('/history', '');
|
||||||
|
if (!peerId) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Peer ID is required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const history = (state.peerHistory.get(peerId) || []).slice(-50);
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming(urlPath, true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(history));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch peer history: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/peers/:id/block - Block peer
|
||||||
|
if (method === 'POST' && urlPath.endsWith('/block')) {
|
||||||
|
try {
|
||||||
|
const peerId = urlPath.split('/api/peers/')[1].replace('/block', '');
|
||||||
|
if (!peerId) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Peer ID is required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.blockedPeers) {
|
||||||
|
state.blockedPeers = new Set();
|
||||||
|
}
|
||||||
|
state.blockedPeers.add(peerId);
|
||||||
|
|
||||||
|
// Save blocked peers to disk
|
||||||
|
await saveBlockedPeers();
|
||||||
|
|
||||||
|
// Disconnect if currently connected
|
||||||
|
if (state.connectedPeers.has(peerId)) {
|
||||||
|
// Find and close the connection
|
||||||
|
// Note: This is a simplified approach - in practice you'd need to track connections
|
||||||
|
logError('Admin', `Peer ${peerId} is currently connected. Blocking will take effect on next connection attempt.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackRequest(urlPath, true);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: `Peer ${peerId} blocked` }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to block peer: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/peers/:id/unblock - Unblock peer
|
||||||
|
if (method === 'POST' && urlPath.endsWith('/unblock')) {
|
||||||
|
try {
|
||||||
|
const peerId = urlPath.split('/api/peers/')[1].replace('/unblock', '');
|
||||||
|
if (!peerId) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Peer ID is required' }));
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.blockedPeers) {
|
||||||
|
state.blockedPeers.delete(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save blocked peers to disk
|
||||||
|
await saveBlockedPeers();
|
||||||
|
|
||||||
|
trackRequest(urlPath, true);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true, message: `Peer ${peerId} unblocked` }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to unblock peer: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/peers/blocked - List blocked peers
|
||||||
|
if (method === 'GET' && urlPath === '/api/peers/blocked') {
|
||||||
|
try {
|
||||||
|
const blocked = state.blockedPeers ? Array.from(state.blockedPeers) : [];
|
||||||
|
trackRequest(urlPath, true);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(blocked));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch blocked peers: ${err.message}`);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handlePeersRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
const { getAllPluginDomains, getPlugin, getAllPluginRegistrations, reloadPlugin, stopPlugin, startPlugin } = require('../../../plugins/plugin-handler');
|
||||||
|
const { logError, logInfo, logDebug } = require('../../../infrastructure/logger');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
async function handlePluginsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plugin settings file path
|
||||||
|
* @param {string} domain - Plugin domain
|
||||||
|
* @returns {string} Settings file path
|
||||||
|
*/
|
||||||
|
function getPluginSettingsPath(domain) {
|
||||||
|
const settingsDir = path.join(process.cwd(), 'cache', 'plugin-settings');
|
||||||
|
return path.join(settingsDir, `${domain}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load plugin settings from file
|
||||||
|
* @param {string} domain - Plugin domain
|
||||||
|
* @returns {Promise<Object>} Plugin settings
|
||||||
|
*/
|
||||||
|
async function loadPluginSettings(domain) {
|
||||||
|
try {
|
||||||
|
const settingsPath = getPluginSettingsPath(domain);
|
||||||
|
const data = await fs.readFile(settingsPath, 'utf8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
// Settings file doesn't exist yet, return empty object
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
logError('PluginsRoute', `Error loading settings for ${domain}: ${err.message}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save plugin settings to file
|
||||||
|
* @param {string} domain - Plugin domain
|
||||||
|
* @param {Object} settings - Settings to save
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function savePluginSettings(domain, settings) {
|
||||||
|
try {
|
||||||
|
const settingsPath = getPluginSettingsPath(domain);
|
||||||
|
const settingsDir = path.dirname(settingsPath);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
await fs.mkdir(settingsDir, { recursive: true });
|
||||||
|
|
||||||
|
// Save settings to file
|
||||||
|
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||||
|
logDebug('PluginsRoute', `Saved settings for plugin ${domain}`);
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error saving settings for ${domain}: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/plugins - List all plugins with their info
|
||||||
|
if (method === 'GET' && urlPath === '/api/plugins') {
|
||||||
|
try {
|
||||||
|
// Get all plugin domains from disk (including stopped ones)
|
||||||
|
const pluginHandler = require('../../../plugins/plugin-handler');
|
||||||
|
// We need to get all domains from disk, not just loaded ones
|
||||||
|
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
|
||||||
|
const allDomains = new Set(['p2ns.admin']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(pluginSitesDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const domain = entry.name;
|
||||||
|
const pluginDir = path.join(pluginSitesDir, domain);
|
||||||
|
const configPath = path.join(pluginDir, 'config.json');
|
||||||
|
try {
|
||||||
|
await fs.access(configPath);
|
||||||
|
const configContent = await fs.readFile(configPath, 'utf8');
|
||||||
|
const config = JSON.parse(configContent);
|
||||||
|
if (config && config.name && config.version) {
|
||||||
|
allDomains.add(domain);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// No valid config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Directory might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginDomains = Array.from(allDomains);
|
||||||
|
const loadedDomains = getAllPluginDomains();
|
||||||
|
const registrations = getAllPluginRegistrations();
|
||||||
|
|
||||||
|
const plugins = await Promise.all(pluginDomains.map(async (domain) => {
|
||||||
|
const plugin = getPlugin(domain);
|
||||||
|
// Plugin might be stopped, check if it exists in plugin-sites
|
||||||
|
const isLoaded = loadedDomains.includes(domain);
|
||||||
|
if (!plugin) {
|
||||||
|
// Check if plugin directory exists to show stopped plugin
|
||||||
|
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
|
||||||
|
const pluginDir = path.join(pluginSitesDir, domain);
|
||||||
|
try {
|
||||||
|
await fs.access(pluginDir);
|
||||||
|
// Plugin exists but is stopped - load config to show basic info
|
||||||
|
const configPath = path.join(pluginDir, 'config.json');
|
||||||
|
let config = {};
|
||||||
|
try {
|
||||||
|
const configData = await fs.readFile(configPath, 'utf8');
|
||||||
|
config = JSON.parse(configData);
|
||||||
|
} catch (err) {
|
||||||
|
// Config might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
name: config?.name || domain,
|
||||||
|
version: config?.version || '1.0.0',
|
||||||
|
description: config?.description || '',
|
||||||
|
author: config?.author || '',
|
||||||
|
homepage: config?.homepage || '',
|
||||||
|
license: config?.license || '',
|
||||||
|
enabled: config?.enabled !== false, // Default to true if not specified
|
||||||
|
status: 'stopped',
|
||||||
|
hasHandler: false,
|
||||||
|
hasWww: false,
|
||||||
|
hasDatabase: false,
|
||||||
|
actions: [],
|
||||||
|
settings: {}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return null; // Plugin directory doesn't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved settings
|
||||||
|
const savedSettings = await loadPluginSettings(domain);
|
||||||
|
|
||||||
|
// Merge saved settings with registered settings (saved values override defaults)
|
||||||
|
const registeredSettings = registrations[domain]?.settings || {};
|
||||||
|
const mergedSettings = {};
|
||||||
|
for (const [key, config] of Object.entries(registeredSettings)) {
|
||||||
|
mergedSettings[key] = {
|
||||||
|
...config,
|
||||||
|
value: savedSettings[key] !== undefined ? savedSettings[key] : config.default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
name: plugin.config?.name || domain,
|
||||||
|
version: plugin.config?.version || '1.0.0',
|
||||||
|
description: plugin.config?.description || '',
|
||||||
|
author: plugin.config?.author || '',
|
||||||
|
homepage: plugin.config?.homepage || '',
|
||||||
|
license: plugin.config?.license || '',
|
||||||
|
icon: plugin.config?.icon || null,
|
||||||
|
enabled: plugin.config?.enabled !== false, // Default to true if not specified
|
||||||
|
status: plugin.handler ? 'loaded' : 'static',
|
||||||
|
hasHandler: !!plugin.handler,
|
||||||
|
hasWww: !!plugin.wwwDir,
|
||||||
|
hasDatabase: !!plugin.db,
|
||||||
|
actions: registrations[domain]?.actions || [],
|
||||||
|
settings: mergedSettings
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filteredPlugins = plugins.filter(Boolean);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ plugins: filteredPlugins }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error listing plugins: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to list plugins' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/plugins/:domain - Get specific plugin info
|
||||||
|
if (method === 'GET' && urlPath.startsWith('/api/plugins/') && !urlPath.includes('/actions/') && !urlPath.includes('/settings') && !urlPath.includes('/reload') && !urlPath.includes('/stop') && !urlPath.includes('/start')) {
|
||||||
|
try {
|
||||||
|
const domain = urlPath.split('/api/plugins/')[1];
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Domain parameter required' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = getPlugin(domain);
|
||||||
|
if (!plugin) {
|
||||||
|
// Check if plugin exists but is stopped
|
||||||
|
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
|
||||||
|
const pluginDir = path.join(pluginSitesDir, domain);
|
||||||
|
try {
|
||||||
|
await fs.access(pluginDir);
|
||||||
|
const configPath = path.join(pluginDir, 'config.json');
|
||||||
|
let config = {};
|
||||||
|
try {
|
||||||
|
const configData = await fs.readFile(configPath, 'utf8');
|
||||||
|
config = JSON.parse(configData);
|
||||||
|
} catch (err) {
|
||||||
|
// Config might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginInfo = {
|
||||||
|
domain,
|
||||||
|
name: config?.name || domain,
|
||||||
|
version: config?.version || '1.0.0',
|
||||||
|
description: config?.description || '',
|
||||||
|
author: config?.author || '',
|
||||||
|
homepage: config?.homepage || '',
|
||||||
|
license: config?.license || '',
|
||||||
|
status: 'stopped',
|
||||||
|
hasHandler: false,
|
||||||
|
hasWww: false,
|
||||||
|
hasDatabase: false,
|
||||||
|
pluginDir,
|
||||||
|
actions: [],
|
||||||
|
settings: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(pluginInfo));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Plugin not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrations = getAllPluginRegistrations();
|
||||||
|
|
||||||
|
// Load saved settings
|
||||||
|
const savedSettings = await loadPluginSettings(domain);
|
||||||
|
|
||||||
|
// Merge saved settings with registered settings
|
||||||
|
const registeredSettings = registrations[domain]?.settings || {};
|
||||||
|
const mergedSettings = {};
|
||||||
|
for (const [key, config] of Object.entries(registeredSettings)) {
|
||||||
|
mergedSettings[key] = {
|
||||||
|
...config,
|
||||||
|
value: savedSettings[key] !== undefined ? savedSettings[key] : config.default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginInfo = {
|
||||||
|
domain,
|
||||||
|
name: plugin.config?.name || domain,
|
||||||
|
version: plugin.config?.version || '1.0.0',
|
||||||
|
description: plugin.config?.description || '',
|
||||||
|
author: plugin.config?.author || '',
|
||||||
|
homepage: plugin.config?.homepage || '',
|
||||||
|
license: plugin.config?.license || '',
|
||||||
|
status: plugin.handler ? 'loaded' : 'static',
|
||||||
|
hasHandler: !!plugin.handler,
|
||||||
|
hasWww: !!plugin.wwwDir,
|
||||||
|
hasDatabase: !!plugin.db,
|
||||||
|
pluginDir: plugin.pluginDir,
|
||||||
|
actions: registrations[domain]?.actions || [],
|
||||||
|
settings: mergedSettings
|
||||||
|
};
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(pluginInfo));
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error getting plugin info: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to get plugin info' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/plugins/:domain/reload - Reload a plugin
|
||||||
|
if (method === 'POST' && urlPath.includes('/reload') && !urlPath.includes('/stop') && !urlPath.includes('/start')) {
|
||||||
|
try {
|
||||||
|
const domain = urlPath.split('/api/plugins/')[1]?.split('/reload')[0];
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Domain parameter required' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo('PluginsRoute', `Reloading plugin ${domain} via API`);
|
||||||
|
|
||||||
|
const reloadedPlugin = await reloadPlugin(domain);
|
||||||
|
|
||||||
|
if (!reloadedPlugin) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to reload plugin' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast update to connected admin clients
|
||||||
|
broadcast({
|
||||||
|
type: 'update-plugins',
|
||||||
|
domain
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `Plugin ${domain} reloaded successfully`,
|
||||||
|
domain
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error reloading plugin: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: `Failed to reload plugin: ${err.message}` }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/plugins/:domain/stop - Stop a plugin
|
||||||
|
if (method === 'POST' && urlPath.includes('/stop')) {
|
||||||
|
try {
|
||||||
|
const domain = urlPath.split('/api/plugins/')[1]?.split('/stop')[0];
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Domain parameter required' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a system plugin that cannot be stopped
|
||||||
|
const SYSTEM_PLUGINS = ['global.profile'];
|
||||||
|
if (SYSTEM_PLUGINS.includes(domain)) {
|
||||||
|
logWarn('PluginsRoute', `Cannot stop system plugin ${domain}`);
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
error: `Cannot stop system plugin: ${domain}. This plugin is required by the system.`,
|
||||||
|
domain,
|
||||||
|
isSystemPlugin: true
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo('PluginsRoute', `Stopping plugin ${domain} via API`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await stopPlugin(domain);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to stop plugin' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error stopping plugin: ${err.message}`);
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast update to connected admin clients
|
||||||
|
broadcast({
|
||||||
|
type: 'update-plugins',
|
||||||
|
domain
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `Plugin ${domain} stopped successfully`,
|
||||||
|
domain
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error stopping plugin: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: `Failed to stop plugin: ${err.message}` }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/plugins/:domain/toggle - Enable/disable a plugin
|
||||||
|
if (method === 'POST' && urlPath.includes('/toggle')) {
|
||||||
|
try {
|
||||||
|
const domain = urlPath.split('/api/plugins/')[1]?.split('/toggle')[0];
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Domain parameter required' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read request body to get enabled state
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk.toString(); });
|
||||||
|
await new Promise(resolve => req.on('end', resolve));
|
||||||
|
|
||||||
|
const data = body ? JSON.parse(body) : {};
|
||||||
|
const enabled = data.enabled !== undefined ? data.enabled : true;
|
||||||
|
|
||||||
|
// Check if this is a system plugin that cannot be disabled
|
||||||
|
const SYSTEM_PLUGINS = ['global.profile'];
|
||||||
|
if (!enabled && SYSTEM_PLUGINS.includes(domain)) {
|
||||||
|
logWarn('PluginsRoute', `Cannot disable system plugin ${domain}`);
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
error: `Cannot disable system plugin: ${domain}. This plugin is required by the system.`,
|
||||||
|
domain,
|
||||||
|
isSystemPlugin: true
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo('PluginsRoute', `${enabled ? 'Enabling' : 'Disabling'} plugin ${domain} via API`);
|
||||||
|
|
||||||
|
// Get plugin directory
|
||||||
|
const pluginSitesDir = path.join(process.cwd(), 'plugin-sites');
|
||||||
|
const pluginDir = path.join(pluginSitesDir, domain);
|
||||||
|
const configPath = path.join(pluginDir, 'config.json');
|
||||||
|
|
||||||
|
// Read current config
|
||||||
|
let config = {};
|
||||||
|
try {
|
||||||
|
const configData = await fs.readFile(configPath, 'utf8');
|
||||||
|
config = JSON.parse(configData);
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Plugin config.json not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update enabled flag (system plugins are always enabled)
|
||||||
|
if (SYSTEM_PLUGINS.includes(domain)) {
|
||||||
|
config.enabled = true;
|
||||||
|
} else {
|
||||||
|
config.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated config
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||||
|
logInfo('PluginsRoute', `Updated config.json for ${domain}: enabled=${enabled}`);
|
||||||
|
|
||||||
|
// Apply the enabled/disabled state by reloading, starting, or stopping the plugin
|
||||||
|
const pluginHandler = require('../../../plugins/plugin-handler');
|
||||||
|
const plugin = pluginHandler.getPlugin(domain);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// Enabling the plugin
|
||||||
|
if (plugin) {
|
||||||
|
// Plugin is currently loaded - reload it to ensure it's properly enabled
|
||||||
|
// This will read the fresh config.json with enabled=true
|
||||||
|
const reloaded = await pluginHandler.reloadPlugin(domain);
|
||||||
|
if (!reloaded) {
|
||||||
|
// Reload failed (might be disabled in config still) - try starting fresh
|
||||||
|
logInfo('PluginsRoute', `Reload failed for ${domain}, attempting fresh start`);
|
||||||
|
await pluginHandler.startPlugin(domain);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plugin is not loaded - start it (this will read config.json with enabled=true)
|
||||||
|
await pluginHandler.startPlugin(domain);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Disabling the plugin
|
||||||
|
if (plugin) {
|
||||||
|
// Plugin is loaded - stop it (this will clear caches and remove from internal domains)
|
||||||
|
await pluginHandler.stopPlugin(domain);
|
||||||
|
} else {
|
||||||
|
// Plugin is not loaded - just clear caches to remove from internal domains
|
||||||
|
pluginHandler.clearInternalDomainsCache();
|
||||||
|
try {
|
||||||
|
const { invalidateEntriesCache } = require('../../../core/core');
|
||||||
|
invalidateEntriesCache();
|
||||||
|
logDebug('PluginsRoute', 'DNS cache invalidated after disabling plugin');
|
||||||
|
} catch (err) {
|
||||||
|
logDebug('PluginsRoute', `Could not invalidate DNS cache: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update proxy server certificates to remove internal domain
|
||||||
|
try {
|
||||||
|
const { updateProxyServerCertificates } = require('../../../networking/internal_domains_proxy');
|
||||||
|
await updateProxyServerCertificates();
|
||||||
|
logDebug('PluginsRoute', 'Proxy server certificates updated after disabling plugin');
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('PluginsRoute', `Could not update proxy server certificates: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast update to connected admin clients
|
||||||
|
broadcast({
|
||||||
|
type: 'update-plugins',
|
||||||
|
domain
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `Plugin ${domain} ${enabled ? 'enabled' : 'disabled'} successfully`,
|
||||||
|
domain,
|
||||||
|
enabled
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error toggling plugin: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: `Failed to toggle plugin: ${err.message}` }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/plugins/:domain/start - Start a plugin
|
||||||
|
if (method === 'POST' && urlPath.includes('/start')) {
|
||||||
|
try {
|
||||||
|
const domain = urlPath.split('/api/plugins/')[1]?.split('/start')[0];
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Domain parameter required' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo('PluginsRoute', `Starting plugin ${domain} via API`);
|
||||||
|
|
||||||
|
const startedPlugin = await startPlugin(domain);
|
||||||
|
|
||||||
|
if (!startedPlugin) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to start plugin' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast update to connected admin clients
|
||||||
|
broadcast({
|
||||||
|
type: 'update-plugins',
|
||||||
|
domain
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `Plugin ${domain} started successfully`,
|
||||||
|
domain
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error starting plugin: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: `Failed to start plugin: ${err.message}` }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/plugins/:domain/actions/:actionName - Execute a plugin action
|
||||||
|
if (method === 'POST' && urlPath.includes('/actions/')) {
|
||||||
|
try {
|
||||||
|
const match = urlPath.match(/\/api\/plugins\/([^\/]+)\/actions\/(.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Invalid action path' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = match[1];
|
||||||
|
const actionName = match[2];
|
||||||
|
|
||||||
|
const plugin = getPlugin(domain);
|
||||||
|
if (!plugin) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Plugin not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrations = getAllPluginRegistrations();
|
||||||
|
const action = registrations[domain]?.actions?.find(a => a.name === actionName);
|
||||||
|
|
||||||
|
if (!action || !action.handler) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Action not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body for parameters
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk.toString(); });
|
||||||
|
await new Promise(resolve => req.on('end', resolve));
|
||||||
|
|
||||||
|
const params = body ? JSON.parse(body) : {};
|
||||||
|
|
||||||
|
// Execute the action (handler may be a proxy function for child process)
|
||||||
|
try {
|
||||||
|
const result = await action.handler(params);
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
result
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error executing action ${actionName} for plugin ${domain}: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
error: `Action execution failed: ${err.message}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error handling action request: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to execute action' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/plugins/:domain/settings - Update plugin settings
|
||||||
|
if (method === 'POST' && urlPath.includes('/settings') && !urlPath.includes('/actions/')) {
|
||||||
|
try {
|
||||||
|
const domain = urlPath.split('/api/plugins/')[1]?.split('/settings')[0];
|
||||||
|
if (!domain) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Domain parameter required' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk.toString(); });
|
||||||
|
await new Promise(resolve => req.on('end', resolve));
|
||||||
|
|
||||||
|
const settings = body ? JSON.parse(body) : {};
|
||||||
|
|
||||||
|
// Save settings to file
|
||||||
|
await savePluginSettings(domain, settings);
|
||||||
|
|
||||||
|
// Broadcast update
|
||||||
|
broadcast({
|
||||||
|
type: 'update-plugin-settings',
|
||||||
|
domain
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `Settings updated for plugin ${domain}`
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('PluginsRoute', `Error updating plugin settings: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to update settings' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handlePluginsRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const { logError, logWarn, logDebug } = require('../../../infrastructure/logger');
|
||||||
|
const { getAvailableIPsForSubnet } = require('../../../networking/virtual_interfaces');
|
||||||
|
const { settingsMetadata, restartRequiredSettings, liveReloadableSettings, envWhitelist, applyLiveSettings } = require('../settings');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
async function handleSettingsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/settings') {
|
||||||
|
try {
|
||||||
|
const settings = {};
|
||||||
|
const metadata = {};
|
||||||
|
|
||||||
|
// Build settings and metadata - always start from settingsMetadata as source of truth
|
||||||
|
Object.keys(settingsMetadata).forEach(key => {
|
||||||
|
let value = process.env[key] || '';
|
||||||
|
// Use default value from metadata if setting is not set
|
||||||
|
if (!value && settingsMetadata[key].default) {
|
||||||
|
value = settingsMetadata[key].default;
|
||||||
|
}
|
||||||
|
settings[key] = value;
|
||||||
|
|
||||||
|
// Always create metadata from the full settingsMetadata object, then add currentValue
|
||||||
|
metadata[key] = {
|
||||||
|
...settingsMetadata[key], // This includes category, type, label, description, etc.
|
||||||
|
currentValue: value || settingsMetadata[key].default || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then, ensure all envWhitelist settings that have metadata are included
|
||||||
|
envWhitelist.forEach(key => {
|
||||||
|
// Only include settings that have metadata (skip file/directory paths that shouldn't be in UI)
|
||||||
|
if (settingsMetadata[key]) {
|
||||||
|
if (!settings.hasOwnProperty(key)) {
|
||||||
|
settings[key] = process.env[key] || '';
|
||||||
|
}
|
||||||
|
// If setting has metadata, ensure it's in metadata with full structure
|
||||||
|
if (!metadata[key]) {
|
||||||
|
metadata[key] = {
|
||||||
|
...settingsMetadata[key],
|
||||||
|
currentValue: settings[key] || settingsMetadata[key].default || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final pass: Ensure ALL settingsMetadata entries are in metadata with complete structure
|
||||||
|
Object.keys(settingsMetadata).forEach(key => {
|
||||||
|
const currentValue = metadata[key]?.currentValue || settings[key] || settingsMetadata[key].default || '';
|
||||||
|
metadata[key] = {
|
||||||
|
...settingsMetadata[key], // Complete source structure (includes category, type, label, etc.)
|
||||||
|
currentValue: currentValue // Preserve the current value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure settings exist for all metadata entries
|
||||||
|
Object.keys(settingsMetadata).forEach(key => {
|
||||||
|
if (!settings.hasOwnProperty(key)) {
|
||||||
|
settings[key] = process.env[key] || settingsMetadata[key].default || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ settings, metadata }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch settings: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch settings' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/subnets') {
|
||||||
|
try {
|
||||||
|
let subnets = [];
|
||||||
|
if (process.env.SUBNETS) {
|
||||||
|
try {
|
||||||
|
subnets = JSON.parse(process.env.SUBNETS);
|
||||||
|
if (!Array.isArray(subnets)) {
|
||||||
|
subnets = [];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Failed to parse SUBNETS: ${err.message}`);
|
||||||
|
subnets = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subnets.length === 0) {
|
||||||
|
const subnetBase = process.env.SUBNET_BASE || '192.168.3';
|
||||||
|
const baseParts = subnetBase.split('.');
|
||||||
|
if (baseParts.length === 3) {
|
||||||
|
subnets = [{
|
||||||
|
base: `${subnetBase}.0`,
|
||||||
|
cidr: 24,
|
||||||
|
startIndex: parseInt(process.env.INITIAL_IP_INDEX || '2', 10),
|
||||||
|
name: 'Default Subnet'
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subnetInfo = subnets.map((subnet, index) => {
|
||||||
|
const available = getAvailableIPsForSubnet(subnet);
|
||||||
|
const used = Array.from(state.domainToIPMap.values()).filter(ip => {
|
||||||
|
const ipParts = ip.split('.');
|
||||||
|
const subnetParts = subnet.base.split('.');
|
||||||
|
return ipParts[0] === subnetParts[0] &&
|
||||||
|
ipParts[1] === subnetParts[1] &&
|
||||||
|
ipParts[2] === subnetParts[2];
|
||||||
|
}).length;
|
||||||
|
return {
|
||||||
|
...subnet,
|
||||||
|
index,
|
||||||
|
available,
|
||||||
|
used,
|
||||||
|
remaining: Math.max(0, available - used)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ subnets: subnetInfo }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch subnets: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch subnets' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/subnets') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { subnets } = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!Array.isArray(subnets)) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'subnets must be an array' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
subnets.forEach((subnet, index) => {
|
||||||
|
if (!subnet || typeof subnet !== 'object') {
|
||||||
|
errors.push(`subnets[${index}]: must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subnet.base || typeof subnet.base !== 'string') {
|
||||||
|
errors.push(`subnets[${index}]: base is required and must be a string`);
|
||||||
|
} else if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(subnet.base)) {
|
||||||
|
errors.push(`subnets[${index}]: base must be a valid IPv4 address`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cidr = parseInt(subnet.cidr, 10);
|
||||||
|
if (isNaN(cidr) || cidr < 1 || cidr > 32) {
|
||||||
|
errors.push(`subnets[${index}]: cidr must be between 1 and 32`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = parseInt(subnet.startIndex || process.env.INITIAL_IP_INDEX || '2', 10);
|
||||||
|
const maxIPs = Math.pow(2, 32 - cidr) - 2;
|
||||||
|
if (isNaN(startIndex) || startIndex < 1 || startIndex > Math.min(254, maxIPs)) {
|
||||||
|
errors.push(`subnets[${index}]: startIndex must be between 1 and ${Math.min(254, maxIPs)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subnet.name || typeof subnet.name !== 'string') {
|
||||||
|
subnet.name = `Subnet ${index + 1}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Validation failed', errors }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.SUBNETS = JSON.stringify(subnets);
|
||||||
|
|
||||||
|
const envContent = envWhitelist.map(key => {
|
||||||
|
if (key === 'SUBNETS') {
|
||||||
|
return `${key}=${JSON.stringify(subnets)}`;
|
||||||
|
}
|
||||||
|
return `${key}=${process.env[key] || ''}`;
|
||||||
|
}).join('\n');
|
||||||
|
await fs.writeFile('.env', envContent);
|
||||||
|
|
||||||
|
state.subnets = subnets;
|
||||||
|
state.currentSubnetIndex = 0;
|
||||||
|
state.subnetIPCounters.clear();
|
||||||
|
subnets.forEach((subnet, index) => {
|
||||||
|
state.subnetIPCounters.set(index, subnet.startIndex || parseInt(process.env.INITIAL_IP_INDEX || '2', 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcast({ type: 'update-settings' });
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
message: 'Subnets updated. Restart required to fully apply changes.',
|
||||||
|
restartRequired: true
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to update subnets: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/update-settings') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { settings } = JSON.parse(body);
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
if (!envWhitelist.includes(key)) {
|
||||||
|
errors.push(`Setting ${key} is not whitelisted`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = settingsMetadata[key];
|
||||||
|
if (meta) {
|
||||||
|
if (meta.type === 'number') {
|
||||||
|
const numValue = parseInt(value, 10);
|
||||||
|
if (isNaN(numValue)) {
|
||||||
|
errors.push(`${meta.label}: must be a number`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (meta.min !== undefined && numValue < meta.min) {
|
||||||
|
errors.push(`${meta.label}: must be at least ${meta.min}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (meta.max !== undefined && numValue > meta.max) {
|
||||||
|
errors.push(`${meta.label}: must be at most ${meta.max}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
process.env[key] = numValue.toString();
|
||||||
|
} else if (meta.type === 'checkbox') {
|
||||||
|
process.env[key] = (value === true || value === 'true' || value === '1') ? 'true' : 'false';
|
||||||
|
} else if (key === 'SUBNETS') {
|
||||||
|
try {
|
||||||
|
const subnets = typeof value === 'string' ? JSON.parse(value) : value;
|
||||||
|
if (!Array.isArray(subnets)) {
|
||||||
|
errors.push('SUBNETS must be an array');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
process.env[key] = JSON.stringify(subnets);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`SUBNETS: invalid JSON - ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (key === 'PUBLIC_DNS_SERVER') {
|
||||||
|
// Validate comma-separated IP addresses
|
||||||
|
const dnsServers = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||||
|
if (dnsServers.length === 0) {
|
||||||
|
errors.push(`${meta.label}: at least one DNS server is required`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const invalidServers = [];
|
||||||
|
dnsServers.forEach((server, index) => {
|
||||||
|
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(server)) {
|
||||||
|
invalidServers.push(`server ${index + 1} (${server})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (invalidServers.length > 0) {
|
||||||
|
errors.push(`${meta.label}: invalid IP addresses: ${invalidServers.join(', ')}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
process.env[key] = value;
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (key === 'SUBNETS') {
|
||||||
|
try {
|
||||||
|
const subnets = typeof value === 'string' ? JSON.parse(value) : value;
|
||||||
|
if (!Array.isArray(subnets)) {
|
||||||
|
errors.push('SUBNETS must be an array');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
process.env[key] = JSON.stringify(subnets);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`SUBNETS: invalid JSON - ${err.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (key === 'PUBLIC_DNS_SERVER') {
|
||||||
|
// Validate comma-separated IP addresses
|
||||||
|
const dnsServers = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||||
|
if (dnsServers.length === 0) {
|
||||||
|
errors.push(`PUBLIC_DNS_SERVER: at least one DNS server is required`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const invalidServers = [];
|
||||||
|
dnsServers.forEach((server, index) => {
|
||||||
|
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(server)) {
|
||||||
|
invalidServers.push(`server ${index + 1} (${server})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (invalidServers.length > 0) {
|
||||||
|
errors.push(`PUBLIC_DNS_SERVER: invalid IP addresses: ${invalidServers.join(', ')}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
process.env[key] = value;
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Validation failed', errors }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envContent = envWhitelist.map(key => {
|
||||||
|
if (key === 'SUBNETS') {
|
||||||
|
return `${key}=${process.env[key] || '[]'}`;
|
||||||
|
}
|
||||||
|
return `${key}=${process.env[key] || ''}`;
|
||||||
|
}).join('\n');
|
||||||
|
await fs.writeFile('.env', envContent);
|
||||||
|
|
||||||
|
const restartRequired = Object.keys(settings).some(key => restartRequiredSettings.includes(key));
|
||||||
|
|
||||||
|
const liveSettings = {};
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
if (liveReloadableSettings.includes(key)) {
|
||||||
|
liveSettings[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(liveSettings).length > 0) {
|
||||||
|
await applyLiveSettings(liveSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast({ type: 'update-settings' });
|
||||||
|
|
||||||
|
if (restartRequired) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
message: 'Settings saved. Some settings require restart to take effect.',
|
||||||
|
restartRequired: true,
|
||||||
|
restartRequiredSettings: Object.keys(settings).filter(k => restartRequiredSettings.includes(k))
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
message: 'Settings saved and applied successfully.',
|
||||||
|
restartRequired: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to update settings: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/network-interfaces') {
|
||||||
|
try {
|
||||||
|
const networkInterfaces = os.networkInterfaces();
|
||||||
|
const interfaces = [];
|
||||||
|
const interfaceSet = new Set();
|
||||||
|
|
||||||
|
// Get OS-specific default interface name
|
||||||
|
const osDefault = os.platform() === 'darwin' ? 'lo0'
|
||||||
|
: os.platform() === 'linux' ? 'lo'
|
||||||
|
: os.platform() === 'win32' ? 'Loopback Pseudo-Interface 1'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Collect all interface names
|
||||||
|
for (const [name, addresses] of Object.entries(networkInterfaces)) {
|
||||||
|
if (!addresses || addresses.length === 0) continue;
|
||||||
|
if (!interfaceSet.has(name)) {
|
||||||
|
interfaceSet.add(name);
|
||||||
|
interfaces.push({
|
||||||
|
value: name,
|
||||||
|
label: name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort interfaces, prioritizing loopback interfaces (lo, lo0) and the OS default
|
||||||
|
interfaces.sort((a, b) => {
|
||||||
|
const aIsDefault = a.value === osDefault;
|
||||||
|
const bIsDefault = b.value === osDefault;
|
||||||
|
if (aIsDefault && !bIsDefault) return -1;
|
||||||
|
if (!aIsDefault && bIsDefault) return 1;
|
||||||
|
|
||||||
|
const aIsLoopback = a.value.startsWith('lo');
|
||||||
|
const bIsLoopback = b.value.startsWith('lo');
|
||||||
|
if (aIsLoopback && !bIsLoopback) return -1;
|
||||||
|
if (!aIsLoopback && bIsLoopback) return 1;
|
||||||
|
|
||||||
|
return a.value.localeCompare(b.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure OS default is in the list if it exists
|
||||||
|
if (osDefault && !interfaceSet.has(osDefault)) {
|
||||||
|
interfaces.unshift({
|
||||||
|
value: osDefault,
|
||||||
|
label: `${osDefault} (default)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ interfaces }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch network interfaces: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/identity') {
|
||||||
|
try {
|
||||||
|
const { getPersistentPublicKey } = require('../../../infrastructure/utils');
|
||||||
|
const publicKey = getPersistentPublicKey();
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
publicKey: publicKey || null,
|
||||||
|
hasIdentity: !!publicKey
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch identity: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/reset-identity') {
|
||||||
|
try {
|
||||||
|
const keypairPath = path.resolve('./cache/keypair.json');
|
||||||
|
|
||||||
|
// Check if keypair file exists
|
||||||
|
try {
|
||||||
|
await fs.access(keypairPath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Keypair file not found' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the keypair file
|
||||||
|
await fs.unlink(keypairPath);
|
||||||
|
logWarn('Admin', 'Identity keypair deleted by user');
|
||||||
|
|
||||||
|
// Clear the keypair from state
|
||||||
|
state.keypair = null;
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
message: 'Identity reset successfully. A new keypair will be generated on next restart. Please restart the application for the changes to take effect.'
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to reset identity: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/recheck-peers - Force peer recheck/rediscovery
|
||||||
|
if (method === 'POST' && urlPath === '/api/recheck-peers') {
|
||||||
|
try {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { logInfo } = require('../../../infrastructure/logger');
|
||||||
|
|
||||||
|
if (!state.swarm) {
|
||||||
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Swarm not initialized' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the topic
|
||||||
|
const TOPIC_SEED = process.env.TOPIC_SEED || 'p2ns-dns';
|
||||||
|
const topic = crypto.createHash('sha256').update(TOPIC_SEED).digest();
|
||||||
|
|
||||||
|
// Flush the DHT to force a recheck for peers
|
||||||
|
logInfo('Admin', 'Forcing peer recheck via swarm.flush()');
|
||||||
|
await state.swarm.flush();
|
||||||
|
|
||||||
|
// Rejoin the topic to trigger new peer discovery
|
||||||
|
// Note: join() is idempotent, so calling it again is safe
|
||||||
|
state.swarm.join(topic, { server: true, client: true });
|
||||||
|
logInfo('Admin', 'Rejoined Hyperswarm topic to trigger peer discovery');
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Peer recheck initiated. New peers will be discovered shortly.'
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to recheck peers: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: `Failed to recheck peers: ${err.message}` }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleSettingsRoutes };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const pathModule = require('path');
|
||||||
|
const { logDebug, logError } = require('../../../infrastructure/logger');
|
||||||
|
|
||||||
|
async function handleStaticRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
const tabs = ['domains', 'host', 'local-dns', 'entries', 'peers', 'certs', 'interfaces', 'logs', 'settings', 'stats'];
|
||||||
|
if (method === 'GET' && urlPath.startsWith('/') && tabs.includes(urlPath.substring(1))) {
|
||||||
|
res.writeHead(302, { 'Location': `/#${urlPath.substring(1)}` });
|
||||||
|
res.end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/') {
|
||||||
|
logDebug('Admin', 'Serving admin panel HTML');
|
||||||
|
try {
|
||||||
|
const html = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', 'index.html'), 'utf8');
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(html);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to serve index.html: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end('Failed to load admin panel');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/tailwind.css') {
|
||||||
|
try {
|
||||||
|
const css = await fs.readFile(pathModule.join(__dirname, '..', '..', '..', 'css', 'tailwind.css'), 'utf8');
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/css' });
|
||||||
|
res.end(css);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to serve tailwind.css: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end('Failed to load Tailwind CSS');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/styles.css') {
|
||||||
|
try {
|
||||||
|
const css = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', 'styles.css'), 'utf8');
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/css' });
|
||||||
|
res.end(css);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to serve styles.css: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end('Failed to load styles');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve frontend JavaScript files
|
||||||
|
if (method === 'GET' && (urlPath === '/admin.js' || urlPath === '/utils.js' || urlPath === '/ws-client.js')) {
|
||||||
|
try {
|
||||||
|
const fileName = urlPath.substring(1); // Remove leading '/'
|
||||||
|
const js = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', fileName), 'utf8');
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
||||||
|
res.end(js);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to serve ${urlPath}: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end('Failed to load script');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve UI module files
|
||||||
|
if (method === 'GET' && urlPath.startsWith('/ui/') && urlPath.endsWith('.js')) {
|
||||||
|
try {
|
||||||
|
const fileName = urlPath.substring(1); // Remove leading '/' -> 'ui/filename.js'
|
||||||
|
const js = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', fileName), 'utf8');
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
||||||
|
res.end(js);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to serve ${urlPath}: ${err.message}`);
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not Found');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlPath === '/favicon.ico') {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not Found');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleStaticRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
const dgram = require('dgram');
|
||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const { getMetrics, getHistoricalData, trackRequestWithTiming, trackRequest } = require('../../../maintenance/metrics');
|
||||||
|
const { getHashForDomain } = require('../../../core/core');
|
||||||
|
const { logDebug, logError } = require('../../../infrastructure/logger');
|
||||||
|
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
||||||
|
const { parseMinutesToMs } = require('../../../infrastructure/utils');
|
||||||
|
const pidusage = require('pidusage');
|
||||||
|
|
||||||
|
// Import db-manager and replication-manager for HyperDB stats
|
||||||
|
let dbManager = null;
|
||||||
|
let replicationManager = null;
|
||||||
|
let driveReplicationManager = null;
|
||||||
|
let driveManager = null;
|
||||||
|
try {
|
||||||
|
dbManager = require('../../../plugins/db-manager');
|
||||||
|
} catch (e) {
|
||||||
|
logDebug('Admin', 'db-manager not available for stats');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
replicationManager = require('../../../plugins/replication-manager');
|
||||||
|
} catch (e) {
|
||||||
|
logDebug('Admin', 'replication-manager not available for stats');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
driveReplicationManager = require('../../../plugins/drive-replication-manager');
|
||||||
|
} catch (e) {
|
||||||
|
logDebug('Admin', 'drive-replication-manager not available for stats');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
driveManager = require('../../../plugins/drive-manager');
|
||||||
|
} catch (e) {
|
||||||
|
logDebug('Admin', 'drive-manager not available for stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/stats') {
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const stats = getMetrics();
|
||||||
|
|
||||||
|
const holesailChildren = [];
|
||||||
|
const pidStatsPromises = [];
|
||||||
|
const pidToChildMap = new Map();
|
||||||
|
|
||||||
|
for (const [id, child] of state.holesailChildren.entries()) {
|
||||||
|
try {
|
||||||
|
const opts = state.holesailOpts.get(id) || {};
|
||||||
|
const info = state.holesailInfos.get(id) || {};
|
||||||
|
const startTime = state.holesailChildStartTimes.get(id);
|
||||||
|
const uptime = startTime ? Date.now() - startTime : 0;
|
||||||
|
const status = child && !child.killed ? 'running' : 'stopped';
|
||||||
|
const pid = child ? child.pid : null;
|
||||||
|
|
||||||
|
if (pid && child && !child.killed) {
|
||||||
|
pidStatsPromises.push(
|
||||||
|
pidusage(pid).then(stats => ({ id, type: 'server', stats })).catch(err => {
|
||||||
|
logDebug('Admin', `Failed to get stats for server child ${id} (PID ${pid}): ${err.message}`);
|
||||||
|
return { id, type: 'server', stats: null };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pidToChildMap.set(id, {
|
||||||
|
id,
|
||||||
|
type: 'server',
|
||||||
|
status,
|
||||||
|
pid,
|
||||||
|
uptime,
|
||||||
|
cpuUsage: null,
|
||||||
|
memoryUsage: null,
|
||||||
|
opts: {
|
||||||
|
port: opts.port,
|
||||||
|
host: opts.host || '0.0.0.0',
|
||||||
|
protocol: opts.udp ? 'udp' : 'tcp',
|
||||||
|
secure: opts.secure || false,
|
||||||
|
domain: opts.domain || null
|
||||||
|
},
|
||||||
|
info: info
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error collecting stats for server child ${id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, child] of state.holesailClientChildren.entries()) {
|
||||||
|
try {
|
||||||
|
const opts = state.holesailClientOpts.get(id) || {};
|
||||||
|
const info = state.holesailClientInfos.get(id) || {};
|
||||||
|
const startTime = state.holesailChildStartTimes.get(id);
|
||||||
|
const uptime = startTime ? Date.now() - startTime : 0;
|
||||||
|
const status = child && !child.killed ? 'running' : 'stopped';
|
||||||
|
const pid = child ? child.pid : null;
|
||||||
|
|
||||||
|
if (pid && child && !child.killed) {
|
||||||
|
pidStatsPromises.push(
|
||||||
|
pidusage(pid).then(stats => ({ id, type: 'client', stats })).catch(err => {
|
||||||
|
logDebug('Admin', `Failed to get stats for client child ${id} (PID ${pid}): ${err.message}`);
|
||||||
|
return { id, type: 'client', stats: null };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pidToChildMap.set(id, {
|
||||||
|
id,
|
||||||
|
type: 'client',
|
||||||
|
status,
|
||||||
|
pid,
|
||||||
|
uptime,
|
||||||
|
cpuUsage: null,
|
||||||
|
memoryUsage: null,
|
||||||
|
opts: {
|
||||||
|
domain: opts.domain || null,
|
||||||
|
port: opts.port,
|
||||||
|
host: opts.host || null,
|
||||||
|
protocol: opts.protocol || 'tcp'
|
||||||
|
},
|
||||||
|
info: info
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error collecting stats for client child ${id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pidStatsResults = await Promise.all(pidStatsPromises);
|
||||||
|
|
||||||
|
for (const result of pidStatsResults) {
|
||||||
|
if (result.stats) {
|
||||||
|
const childData = pidToChildMap.get(result.id);
|
||||||
|
if (childData) {
|
||||||
|
childData.cpuUsage = {
|
||||||
|
user: result.stats.cpu / 2,
|
||||||
|
system: result.stats.cpu / 2,
|
||||||
|
percentage: result.stats.cpu
|
||||||
|
};
|
||||||
|
childData.memoryUsage = {
|
||||||
|
rss: result.stats.memory,
|
||||||
|
heapUsed: result.stats.memory * 0.8,
|
||||||
|
heapTotal: result.stats.memory,
|
||||||
|
external: 0,
|
||||||
|
arrayBuffers: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const childData of pidToChildMap.values()) {
|
||||||
|
holesailChildren.push(childData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedConnections = new Set();
|
||||||
|
for (const child of holesailChildren) {
|
||||||
|
if (child.opts && child.opts.domain && child.opts.port) {
|
||||||
|
managedConnections.add(`${child.opts.domain}:${child.opts.port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const p2pDomainEntries = [];
|
||||||
|
const hashPromises = [];
|
||||||
|
|
||||||
|
for (const [key, holesail] of state.holesails.entries()) {
|
||||||
|
try {
|
||||||
|
if (managedConnections.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyParts = key.split(':');
|
||||||
|
if (keyParts.length !== 2) {
|
||||||
|
logDebug('Admin', `Skipping invalid holesail key format: ${key}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = keyParts[0];
|
||||||
|
const port = parseInt(keyParts[1], 10);
|
||||||
|
|
||||||
|
if (isNaN(port)) {
|
||||||
|
logDebug('Admin', `Skipping holesail key with invalid port: ${key}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = state.domainToIPMap.get ? state.domainToIPMap.get(domain) : state.domainToIPMap[domain];
|
||||||
|
const isPersistent = state.persistentConnections && state.persistentConnections.has(key);
|
||||||
|
const status = holesail && typeof holesail === 'object' ? 'running' : 'stopped';
|
||||||
|
|
||||||
|
let holesailInfo = null;
|
||||||
|
try {
|
||||||
|
if (holesail && typeof holesail === 'object' && holesail.info) {
|
||||||
|
holesailInfo = holesail.info;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Info not available
|
||||||
|
}
|
||||||
|
|
||||||
|
p2pDomainEntries.push({
|
||||||
|
key,
|
||||||
|
domain,
|
||||||
|
port,
|
||||||
|
ip,
|
||||||
|
isPersistent,
|
||||||
|
status,
|
||||||
|
holesailInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
hashPromises.push(
|
||||||
|
getHashForDomain(domain).catch(err => {
|
||||||
|
logDebug('Admin', `Could not get hash for domain ${domain}: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error processing p2p domain connection ${key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashResults = await Promise.all(hashPromises);
|
||||||
|
|
||||||
|
let mainProcessStats = null;
|
||||||
|
try {
|
||||||
|
mainProcessStats = await pidusage(process.pid);
|
||||||
|
} catch (err) {
|
||||||
|
logDebug('Admin', `Failed to get main process stats for p2p connections: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p2pConnectionCount = p2pDomainEntries.length || 1;
|
||||||
|
let perConnectionCpu = null;
|
||||||
|
let perConnectionMemory = null;
|
||||||
|
|
||||||
|
if (mainProcessStats) {
|
||||||
|
perConnectionCpu = {
|
||||||
|
user: mainProcessStats.cpu / (p2pConnectionCount * 2),
|
||||||
|
system: mainProcessStats.cpu / (p2pConnectionCount * 2),
|
||||||
|
percentage: mainProcessStats.cpu / p2pConnectionCount
|
||||||
|
};
|
||||||
|
|
||||||
|
perConnectionMemory = {
|
||||||
|
rss: Math.floor(mainProcessStats.memory / p2pConnectionCount),
|
||||||
|
heapUsed: Math.floor((mainProcessStats.memory * 0.8) / p2pConnectionCount),
|
||||||
|
heapTotal: Math.floor(mainProcessStats.memory / p2pConnectionCount),
|
||||||
|
external: 0,
|
||||||
|
arrayBuffers: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < p2pDomainEntries.length; i++) {
|
||||||
|
try {
|
||||||
|
const entry = p2pDomainEntries[i];
|
||||||
|
const hash = hashResults[i];
|
||||||
|
const startTime = state.holesailStartTimes && state.holesailStartTimes.get(entry.key);
|
||||||
|
const uptime = startTime ? Date.now() - startTime : 0;
|
||||||
|
|
||||||
|
let timeRemaining = null;
|
||||||
|
if (!entry.isPersistent && process.env.FULL_PERSISTENCE !== 'true' && startTime) {
|
||||||
|
const timeoutDuration = parseMinutesToMs(process.env.HOLESAIL_TIMEOUT || '5');
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const remaining = Math.max(0, timeoutDuration - elapsed);
|
||||||
|
timeRemaining = remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
holesailChildren.push({
|
||||||
|
id: entry.key,
|
||||||
|
type: 'p2p-domain',
|
||||||
|
status: entry.status,
|
||||||
|
pid: process.pid,
|
||||||
|
uptime: uptime,
|
||||||
|
timeRemaining: timeRemaining,
|
||||||
|
cpuUsage: perConnectionCpu,
|
||||||
|
memoryUsage: perConnectionMemory,
|
||||||
|
opts: {
|
||||||
|
domain: entry.domain,
|
||||||
|
port: entry.port,
|
||||||
|
host: entry.ip || null,
|
||||||
|
protocol: 'tcp'
|
||||||
|
},
|
||||||
|
persistent: entry.isPersistent,
|
||||||
|
hash: hash || null,
|
||||||
|
info: entry.holesailInfo || null
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error creating stats entry for p2p domain connection ${p2pDomainEntries[i].key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.holesailChildren = holesailChildren;
|
||||||
|
|
||||||
|
// Collect Peer Channels stats
|
||||||
|
const peerChannelsStats = collectPeerChannelsStats();
|
||||||
|
stats.peerChannels = peerChannelsStats;
|
||||||
|
|
||||||
|
// Collect HyperDB stats
|
||||||
|
const hyperdbStats = collectHyperDBStats();
|
||||||
|
stats.hyperdb = hyperdbStats;
|
||||||
|
|
||||||
|
// Collect Hyperdrive stats
|
||||||
|
const hyperdriveStats = collectHyperdriveStats();
|
||||||
|
stats.hyperdrive = hyperdriveStats;
|
||||||
|
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming('/api/stats', true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(stats));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error in /api/stats: ${err.message}`, err);
|
||||||
|
trackRequest('/api/stats', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/stats/historical') {
|
||||||
|
try {
|
||||||
|
let minutes = 60;
|
||||||
|
if (req.url.includes('?')) {
|
||||||
|
const queryString = req.url.split('?')[1];
|
||||||
|
const params = new URLSearchParams(queryString);
|
||||||
|
const minutesParam = params.get('minutes');
|
||||||
|
if (minutesParam) {
|
||||||
|
minutes = parseInt(minutesParam, 10);
|
||||||
|
if (isNaN(minutes) || minutes < 1) minutes = 60;
|
||||||
|
if (minutes > 1440) minutes = 1440;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const startTime = Date.now();
|
||||||
|
const historical = getHistoricalData(minutes);
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
trackRequestWithTiming('/api/stats/historical', true, responseTime);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(historical));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error in /api/stats/historical: ${err.message}`, err);
|
||||||
|
trackRequest('/api/stats/historical', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect comprehensive Peer Channels statistics
|
||||||
|
*/
|
||||||
|
function collectPeerChannelsStats() {
|
||||||
|
const stats = {
|
||||||
|
totalPlugins: 0,
|
||||||
|
totalProtocols: 0,
|
||||||
|
totalPeerConnections: 0,
|
||||||
|
openChannels: 0,
|
||||||
|
closedChannels: 0,
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!state.pluginChannels || state.pluginChannels.size === 0) {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.totalPlugins = state.pluginChannels.size;
|
||||||
|
|
||||||
|
for (const [pluginDomain, protocolMap] of state.pluginChannels.entries()) {
|
||||||
|
const handlers = state.pluginChannelHandlers?.get(pluginDomain);
|
||||||
|
const pluginInfo = {
|
||||||
|
domain: pluginDomain,
|
||||||
|
protocols: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [protocol, channelInfo] of protocolMap.entries()) {
|
||||||
|
stats.totalProtocols++;
|
||||||
|
const handler = handlers?.get(protocol);
|
||||||
|
|
||||||
|
const protocolInfo = {
|
||||||
|
name: protocol,
|
||||||
|
fullName: `${pluginDomain}-${protocol}`,
|
||||||
|
encoding: handler?.encoding || 'unknown',
|
||||||
|
autoReconnect: handler?.autoReconnect !== false,
|
||||||
|
peerCount: 0,
|
||||||
|
openCount: 0,
|
||||||
|
closedCount: 0,
|
||||||
|
peers: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (channelInfo.peerChannels) {
|
||||||
|
protocolInfo.peerCount = channelInfo.peerChannels.size;
|
||||||
|
stats.totalPeerConnections += channelInfo.peerChannels.size;
|
||||||
|
|
||||||
|
for (const [peerId, peerChannel] of channelInfo.peerChannels.entries()) {
|
||||||
|
const isOpen = peerChannel.channel?.opened || false;
|
||||||
|
const isClosed = peerChannel.channel?.closed || false;
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
protocolInfo.openCount++;
|
||||||
|
stats.openChannels++;
|
||||||
|
} else {
|
||||||
|
protocolInfo.closedCount++;
|
||||||
|
stats.closedChannels++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerInfo = {
|
||||||
|
peerId: peerId.substring(0, 16) + '...',
|
||||||
|
fullPeerId: peerId,
|
||||||
|
status: isClosed ? 'closed' : (isOpen ? 'open' : 'connecting'),
|
||||||
|
localOpened: peerChannel.localOpened || false,
|
||||||
|
remoteOpened: peerChannel.remoteOpened || false,
|
||||||
|
openedAt: peerChannel.openedAt || null,
|
||||||
|
closedAt: peerChannel.closedAt || null,
|
||||||
|
lastRemoteOpen: peerChannel.lastRemoteOpen || null,
|
||||||
|
reopenAttempts: peerChannel.reopenAttempts || 0,
|
||||||
|
lastReopenAttempt: peerChannel.lastReopenAttempt || null,
|
||||||
|
connectionValid: peerChannel.conn && !peerChannel.conn.destroyed,
|
||||||
|
muxValid: !!peerChannel.mux
|
||||||
|
};
|
||||||
|
|
||||||
|
protocolInfo.peers.push(peerInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginInfo.protocols.push(protocolInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.plugins.push(pluginInfo);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error collecting peer channels stats: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect comprehensive HyperDB statistics
|
||||||
|
*/
|
||||||
|
function collectHyperDBStats() {
|
||||||
|
const stats = {
|
||||||
|
totalDatabases: 0,
|
||||||
|
totalStores: 0,
|
||||||
|
replicationActive: 0,
|
||||||
|
databases: []
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!dbManager) {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all plugin stores
|
||||||
|
const pluginStores = dbManager.getAllPluginStores ? dbManager.getAllPluginStores() : new Map();
|
||||||
|
stats.totalStores = pluginStores.size;
|
||||||
|
|
||||||
|
for (const [pluginDomain, store] of pluginStores.entries()) {
|
||||||
|
const dbInfo = {
|
||||||
|
pluginDomain,
|
||||||
|
pluginVersion: null,
|
||||||
|
storeReady: false,
|
||||||
|
coreKey: null,
|
||||||
|
coreKeyShort: null,
|
||||||
|
databaseOpen: false,
|
||||||
|
replicationActive: false,
|
||||||
|
writable: false,
|
||||||
|
length: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get plugin version from config
|
||||||
|
try {
|
||||||
|
const pluginHandler = require('../../../plugins/plugin-handler');
|
||||||
|
const plugin = pluginHandler.getPlugin(pluginDomain);
|
||||||
|
if (plugin && plugin.config && plugin.config.version) {
|
||||||
|
dbInfo.pluginVersion = plugin.config.version;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore version lookup errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check store status
|
||||||
|
if (store && !store.closed) {
|
||||||
|
dbInfo.storeReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get core key
|
||||||
|
const coreKey = dbManager.getPluginCoreKey ? dbManager.getPluginCoreKey(pluginDomain) : null;
|
||||||
|
if (coreKey) {
|
||||||
|
dbInfo.coreKey = coreKey.toString('hex');
|
||||||
|
dbInfo.coreKeyShort = coreKey.toString('hex').substring(0, 16) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get core instance for more details
|
||||||
|
const core = dbManager.getPluginCore ? dbManager.getPluginCore(pluginDomain) : null;
|
||||||
|
if (core) {
|
||||||
|
dbInfo.writable = core.writable || false;
|
||||||
|
dbInfo.length = core.length || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database instance
|
||||||
|
const db = dbManager.getDatabaseInstance ? dbManager.getDatabaseInstance(pluginDomain) : null;
|
||||||
|
if (db && !db.closed) {
|
||||||
|
dbInfo.databaseOpen = true;
|
||||||
|
stats.totalDatabases++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check replication status
|
||||||
|
if (replicationManager && replicationManager.isReplicationActive) {
|
||||||
|
dbInfo.replicationActive = replicationManager.isReplicationActive(pluginDomain);
|
||||||
|
if (dbInfo.replicationActive) {
|
||||||
|
stats.replicationActive++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logDebug('Admin', `Error getting HyperDB info for ${pluginDomain}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.databases.push(dbInfo);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error collecting HyperDB stats: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect Hyperdrive statistics
|
||||||
|
*/
|
||||||
|
function collectHyperdriveStats() {
|
||||||
|
const stats = {
|
||||||
|
totalDrives: 0,
|
||||||
|
replicationActive: 0,
|
||||||
|
drives: []
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!driveManager) {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all drives from drive manager
|
||||||
|
const allDrives = driveManager.getAllDriveInstances ? driveManager.getAllDriveInstances() : new Map();
|
||||||
|
stats.totalDrives = allDrives.size;
|
||||||
|
|
||||||
|
for (const [driveKey, driveInfo] of allDrives.entries()) {
|
||||||
|
const { pluginDomain, driveName, drive } = driveInfo;
|
||||||
|
const info = {
|
||||||
|
pluginDomain,
|
||||||
|
driveName,
|
||||||
|
driveReady: false,
|
||||||
|
discoveryKey: null,
|
||||||
|
discoveryKeyShort: null,
|
||||||
|
replicationActive: false,
|
||||||
|
writable: false,
|
||||||
|
version: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (drive) {
|
||||||
|
info.driveReady = !drive.closed;
|
||||||
|
info.writable = drive.writable || false;
|
||||||
|
info.version = drive.version || 0;
|
||||||
|
|
||||||
|
if (drive.discoveryKey) {
|
||||||
|
info.discoveryKey = drive.discoveryKey.toString('hex');
|
||||||
|
info.discoveryKeyShort = drive.discoveryKey.toString('hex').substring(0, 16) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check replication status
|
||||||
|
if (driveReplicationManager && driveReplicationManager.isReplicationActive) {
|
||||||
|
info.replicationActive = driveReplicationManager.isReplicationActive(pluginDomain, driveName);
|
||||||
|
if (info.replicationActive) {
|
||||||
|
stats.replicationActive++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logDebug('Admin', `Error getting Hyperdrive info for ${pluginDomain}/${driveName}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.drives.push(info);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error collecting Hyperdrive stats: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleStatsRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const url = require('url');
|
||||||
|
const state = require('../../../infrastructure/state');
|
||||||
|
const { trackRequest } = require('../../../maintenance/metrics');
|
||||||
|
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
||||||
|
const { metrics } = require('../../../maintenance/metrics');
|
||||||
|
|
||||||
|
async function handleStatusRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/status') {
|
||||||
|
try {
|
||||||
|
const status = {
|
||||||
|
isMaster: state.isMaster,
|
||||||
|
isConnected: !!state.dnsPass,
|
||||||
|
peersCount: state.connectedPeers.size
|
||||||
|
};
|
||||||
|
trackRequest('/api/status', true);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(status));
|
||||||
|
} catch (err) {
|
||||||
|
trackRequest('/api/status', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/health') {
|
||||||
|
try {
|
||||||
|
const query = url.parse(req.url, true).query;
|
||||||
|
const probeType = query.probe || 'liveness';
|
||||||
|
|
||||||
|
const dnsHealthy = !!state.dnsPass && state.dnsPass.ready;
|
||||||
|
const proxyHealthy = process.env.DISABLE_PROXY_SERVER !== 'true';
|
||||||
|
const swarmHealthy = state.connectedPeers !== undefined;
|
||||||
|
|
||||||
|
const corestoreHealthy = state.dnsPass && state.dnsPass.base && state.dnsPass.base.writable !== undefined;
|
||||||
|
const hyperswarmHealthy = swarmHealthy;
|
||||||
|
|
||||||
|
const dnsServerHealthy = process.env.DISABLE_DNS_SERVER !== 'true';
|
||||||
|
const httpsServerHealthy = process.env.DISABLE_PROXY_SERVER !== 'true';
|
||||||
|
|
||||||
|
const allServicesHealthy = dnsHealthy && proxyHealthy && swarmHealthy && corestoreHealthy && hyperswarmHealthy;
|
||||||
|
const status = allServicesHealthy ? 'healthy' : 'degraded';
|
||||||
|
|
||||||
|
const health = {
|
||||||
|
status: status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: Date.now() - (metrics?.startTime || Date.now()),
|
||||||
|
probe: probeType,
|
||||||
|
services: {
|
||||||
|
dns: {
|
||||||
|
enabled: dnsServerHealthy,
|
||||||
|
healthy: dnsHealthy,
|
||||||
|
initialized: !!state.dnsPass,
|
||||||
|
details: {
|
||||||
|
passReady: !!state.dnsPass?.ready,
|
||||||
|
domainsCount: state.domainToIPMap?.size || 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
enabled: httpsServerHealthy,
|
||||||
|
healthy: proxyHealthy,
|
||||||
|
details: {
|
||||||
|
httpsEnabled: process.env.DISABLE_PROXY_SERVER !== 'true',
|
||||||
|
httpEnabled: process.env.DISABLE_PROXY_SERVER !== 'true'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
swarm: {
|
||||||
|
healthy: swarmHealthy,
|
||||||
|
details: {
|
||||||
|
connectedPeers: state.connectedPeers?.size || 0,
|
||||||
|
isMaster: state.isMaster || false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
corestore: {
|
||||||
|
healthy: corestoreHealthy,
|
||||||
|
details: {
|
||||||
|
initialized: !!state.dnsPass,
|
||||||
|
writable: state.dnsPass?.base?.writable || false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hyperswarm: {
|
||||||
|
healthy: hyperswarmHealthy,
|
||||||
|
details: {
|
||||||
|
connectedPeers: state.connectedPeers?.size || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (probeType === 'readiness') {
|
||||||
|
const ready = allServicesHealthy && state.dnsPass && state.dnsPass.ready;
|
||||||
|
if (!ready) {
|
||||||
|
trackRequest('/api/health', false);
|
||||||
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ...health, status: 'not_ready' }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = allServicesHealthy ? 200 : 503;
|
||||||
|
trackRequest('/api/health', allServicesHealthy);
|
||||||
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(health));
|
||||||
|
} catch (err) {
|
||||||
|
trackRequest('/api/health', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleStatusRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
const { logError, logInfo } = require('../../infrastructure/logger');
|
||||||
|
const { parseSecondsToMs, parseMinutesToMs } = require('../../infrastructure/utils');
|
||||||
|
|
||||||
|
// Settings that require restart to take effect
|
||||||
|
const restartRequiredSettings = [
|
||||||
|
'DNS_PORT',
|
||||||
|
'HTTPS_PORT',
|
||||||
|
'HTTP_PORT',
|
||||||
|
'INTERNAL_PORT',
|
||||||
|
'STORAGE_DIR',
|
||||||
|
'DOMAINS_FILE',
|
||||||
|
'LOCAL_DNS_FILE',
|
||||||
|
'HOLESAIL_SERVERS_FILE',
|
||||||
|
'HOLESAIL_CLIENTS_FILE',
|
||||||
|
'SELECTOR_CACHE_FILE',
|
||||||
|
'PEER_HISTORY_FILE',
|
||||||
|
'CERTS_DIR',
|
||||||
|
'SUBNET_BASE',
|
||||||
|
'SUBNET_NAME',
|
||||||
|
'INITIAL_IP_INDEX',
|
||||||
|
'SUBNETS',
|
||||||
|
'TOPIC_SEED',
|
||||||
|
'DISABLE_DNS_SERVER',
|
||||||
|
'DISABLE_PROXY_SERVER',
|
||||||
|
'DISABLE_VIRTUAL_INTERFACES',
|
||||||
|
'BACKUP_DIR',
|
||||||
|
'BACKUP_INTERVAL',
|
||||||
|
'METRICS_RETENTION_MS',
|
||||||
|
'METRICS_SAMPLING_RATE',
|
||||||
|
'METRICS_AGGREGATION_INTERVAL',
|
||||||
|
'METRICS_MAX_BUFFER_SIZE',
|
||||||
|
'CONSENSUS_QUORUM_THRESHOLD',
|
||||||
|
'CONSENSUS_MIN_VOTES',
|
||||||
|
'CONSENSUS_TIE_BREAKER',
|
||||||
|
'CONSENSUS_VOTE_VALIDATION',
|
||||||
|
'CONSENSUS_IMMEDIATE_UPDATE',
|
||||||
|
'SWARM_KEEPALIVE_INTERVAL',
|
||||||
|
'CHANNEL_KEEPALIVE_INTERVAL',
|
||||||
|
'CHANNEL_KEEPALIVE_TIMEOUT'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Settings that can be live-reloaded
|
||||||
|
const liveReloadableSettings = [
|
||||||
|
'LOG_LEVEL',
|
||||||
|
'RATE_LIMIT_MAX_REQUESTS',
|
||||||
|
'RATE_LIMIT_WINDOW_MS',
|
||||||
|
'DNS_POOL_SIZE',
|
||||||
|
'PUBLIC_DNS_SERVER',
|
||||||
|
'HOLESAIL_TIMEOUT',
|
||||||
|
'PORT_CHECK_TIMEOUT',
|
||||||
|
'ALLOW_ANY_WRITER_INVITES',
|
||||||
|
'FULL_PERSISTENCE',
|
||||||
|
'BACKUP_RETENTION',
|
||||||
|
'DISABLE_AUTO_SUBSCRIPTION'
|
||||||
|
];
|
||||||
|
|
||||||
|
const envWhitelist = [
|
||||||
|
'LOG_LEVEL',
|
||||||
|
'STORAGE_DIR',
|
||||||
|
'DISABLE_PROXY_SERVER',
|
||||||
|
'HTTPS_PORT',
|
||||||
|
'HTTP_PORT',
|
||||||
|
'TOPIC_SEED',
|
||||||
|
'DOMAINS_FILE',
|
||||||
|
'DISABLE_DNS_SERVER',
|
||||||
|
'DNS_PORT',
|
||||||
|
'CERTS_DIR',
|
||||||
|
'LOCAL_DNS_FILE',
|
||||||
|
'HOLESAIL_SERVERS_FILE',
|
||||||
|
'HOLESAIL_CLIENTS_FILE',
|
||||||
|
'PUBLIC_DNS_SERVER',
|
||||||
|
'SUBNET_NAME',
|
||||||
|
'INITIAL_IP_INDEX',
|
||||||
|
'SUBNET_BASE',
|
||||||
|
'SUBNETS',
|
||||||
|
'INTERNAL_PORT',
|
||||||
|
'HOLESAIL_TIMEOUT',
|
||||||
|
'PORT_CHECK_TIMEOUT',
|
||||||
|
'FULL_PERSISTENCE',
|
||||||
|
'ALLOW_ANY_WRITER_INVITES',
|
||||||
|
'DISABLE_AUTO_SUBSCRIPTION',
|
||||||
|
'SELECTOR_CACHE_FILE',
|
||||||
|
'PEER_HISTORY_FILE',
|
||||||
|
'RATE_LIMIT_MAX_REQUESTS',
|
||||||
|
'RATE_LIMIT_WINDOW_MS',
|
||||||
|
'DNS_POOL_SIZE',
|
||||||
|
'BACKUP_RETENTION',
|
||||||
|
'BACKUP_DIR',
|
||||||
|
'BACKUP_INTERVAL',
|
||||||
|
'DISABLE_VIRTUAL_INTERFACES',
|
||||||
|
'METRICS_RETENTION_MS',
|
||||||
|
'METRICS_SAMPLING_RATE',
|
||||||
|
'METRICS_AGGREGATION_INTERVAL',
|
||||||
|
'METRICS_MAX_BUFFER_SIZE',
|
||||||
|
'CONSENSUS_QUORUM_THRESHOLD',
|
||||||
|
'CONSENSUS_MIN_VOTES',
|
||||||
|
'CONSENSUS_TIE_BREAKER',
|
||||||
|
'CONSENSUS_VOTE_VALIDATION',
|
||||||
|
'CONSENSUS_IMMEDIATE_UPDATE',
|
||||||
|
'SWARM_KEEPALIVE_INTERVAL',
|
||||||
|
'CHANNEL_KEEPALIVE_INTERVAL',
|
||||||
|
'CHANNEL_KEEPALIVE_TIMEOUT'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply live settings changes without requiring restart
|
||||||
|
* @param {object} settings - Settings to apply
|
||||||
|
*/
|
||||||
|
async function applyLiveSettings(settings) {
|
||||||
|
try {
|
||||||
|
// Update logger if LOG_LEVEL changed (do this first so subsequent logs use new level)
|
||||||
|
if (settings.LOG_LEVEL !== undefined) {
|
||||||
|
const { updateLogLevel } = require('../../infrastructure/logger');
|
||||||
|
updateLogLevel(parseInt(settings.LOG_LEVEL, 10));
|
||||||
|
// Use console.log here since logger was just updated
|
||||||
|
console.log(`[Admin] Log level updated to ${settings.LOG_LEVEL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rate limiter if rate limit settings changed
|
||||||
|
if (settings.RATE_LIMIT_MAX_REQUESTS !== undefined || settings.RATE_LIMIT_WINDOW_MS !== undefined) {
|
||||||
|
const { updateRateLimiter } = require('../../infrastructure/rate_limit');
|
||||||
|
const maxRequests = settings.RATE_LIMIT_MAX_REQUESTS !== undefined
|
||||||
|
? parseInt(settings.RATE_LIMIT_MAX_REQUESTS, 10)
|
||||||
|
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10);
|
||||||
|
const windowMs = settings.RATE_LIMIT_WINDOW_MS !== undefined
|
||||||
|
? parseSecondsToMs(settings.RATE_LIMIT_WINDOW_MS)
|
||||||
|
: parseSecondsToMs(process.env.RATE_LIMIT_WINDOW_MS || '60');
|
||||||
|
updateRateLimiter(maxRequests, windowMs);
|
||||||
|
logInfo('Admin', `Rate limiter updated: maxRequests=${maxRequests}, windowMs=${windowMs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DNS pool if DNS_POOL_SIZE changed
|
||||||
|
// Note: PUBLIC_DNS_SERVER is already read from process.env at query time, so it's live-reloadable
|
||||||
|
if (settings.DNS_POOL_SIZE !== undefined) {
|
||||||
|
const { updateDnsPool } = require('../../networking/dns_pool');
|
||||||
|
const poolSize = parseInt(settings.DNS_POOL_SIZE, 10);
|
||||||
|
updateDnsPool(poolSize);
|
||||||
|
logInfo('Admin', `DNS pool updated: size=${poolSize}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update backup retention if BACKUP_RETENTION changed
|
||||||
|
if (settings.BACKUP_RETENTION !== undefined) {
|
||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const retentionCount = parseInt(settings.BACKUP_RETENTION, 10);
|
||||||
|
state.backupRetentionCount = retentionCount;
|
||||||
|
logInfo('Admin', `Backup retention updated: ${retentionCount} backups`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other settings (HOLESAIL_TIMEOUT, PORT_CHECK_TIMEOUT, ALLOW_ANY_WRITER_INVITES, PUBLIC_DNS_SERVER, FULL_PERSISTENCE)
|
||||||
|
// are already read from process.env at runtime, so no action needed
|
||||||
|
|
||||||
|
logInfo('Admin', 'Live settings applied successfully');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error applying live settings: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings metadata with types, descriptions, and categories
|
||||||
|
const settingsMetadata = {
|
||||||
|
// Network & Ports
|
||||||
|
'DNS_PORT': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Network & Ports',
|
||||||
|
label: 'DNS Server Port',
|
||||||
|
description: 'Port for the DNS server (default: 53, requires sudo)',
|
||||||
|
default: '53',
|
||||||
|
min: 1,
|
||||||
|
max: 65535
|
||||||
|
},
|
||||||
|
'HTTPS_PORT': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Network & Ports',
|
||||||
|
label: 'HTTPS Proxy Port',
|
||||||
|
description: 'Port for HTTPS proxy server (default: 443, requires sudo)',
|
||||||
|
default: '443',
|
||||||
|
min: 1,
|
||||||
|
max: 65535
|
||||||
|
},
|
||||||
|
'HTTP_PORT': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Network & Ports',
|
||||||
|
label: 'HTTP Redirect Port',
|
||||||
|
description: 'Port for HTTP redirect server (default: 80, requires sudo)',
|
||||||
|
default: '80',
|
||||||
|
min: 1,
|
||||||
|
max: 65535
|
||||||
|
},
|
||||||
|
'INTERNAL_PORT': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Network & Ports',
|
||||||
|
label: 'Internal Holesail Port',
|
||||||
|
description: 'Port used by Holesail clients for tunneling (default: 8080)',
|
||||||
|
default: '8080',
|
||||||
|
min: 1,
|
||||||
|
max: 65535
|
||||||
|
},
|
||||||
|
// Backup
|
||||||
|
'BACKUP_RETENTION': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Backup',
|
||||||
|
label: 'Backup Retention Count',
|
||||||
|
description: 'Maximum number of backups to keep (default: 25). Older backups will be automatically deleted.',
|
||||||
|
default: '25',
|
||||||
|
min: 1,
|
||||||
|
max: 1000
|
||||||
|
},
|
||||||
|
'BACKUP_INTERVAL': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Backup',
|
||||||
|
label: 'Backup Interval (minutes)',
|
||||||
|
description: 'Automatic backup interval in minutes (default: 720 = 12 hours)',
|
||||||
|
default: '720',
|
||||||
|
min: 1
|
||||||
|
},
|
||||||
|
// Network Configuration
|
||||||
|
'SUBNET_BASE': {
|
||||||
|
type: 'text',
|
||||||
|
category: 'Network Configuration',
|
||||||
|
label: 'Subnet Base',
|
||||||
|
description: 'IP subnet base for virtual interfaces (default: 192.168.3.)',
|
||||||
|
default: '192.168.3.'
|
||||||
|
},
|
||||||
|
'SUBNET_NAME': {
|
||||||
|
type: 'select',
|
||||||
|
category: 'Network Configuration',
|
||||||
|
label: 'Subnet Interface Name',
|
||||||
|
description: 'Network interface name for virtual IPs (default: lo0 on macOS, lo on Linux)',
|
||||||
|
default: '',
|
||||||
|
options: [] // Options will be populated dynamically from /api/network-interfaces
|
||||||
|
},
|
||||||
|
'INITIAL_IP_INDEX': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Network Configuration',
|
||||||
|
label: 'Initial IP Index',
|
||||||
|
description: 'Starting IP index for virtual interfaces (default: 2)',
|
||||||
|
default: '2',
|
||||||
|
min: 1,
|
||||||
|
max: 254
|
||||||
|
},
|
||||||
|
'SUBNETS': {
|
||||||
|
type: 'text',
|
||||||
|
category: 'Network Configuration',
|
||||||
|
label: 'Subnets Configuration',
|
||||||
|
description: 'JSON array of subnet configurations. Use the Subnet Configuration section in Settings to manage this.',
|
||||||
|
default: '[]'
|
||||||
|
},
|
||||||
|
'PUBLIC_DNS_SERVER': {
|
||||||
|
type: 'text',
|
||||||
|
category: 'Network Configuration',
|
||||||
|
label: 'Public DNS Server',
|
||||||
|
description: 'Public DNS server(s) for fallback resolution. Supports comma-separated list (e.g., 1.1.1.1,8.8.8.8,9.9.9.9). Uses failover strategy - tries each server in order until one succeeds. Default: 1.1.1.1',
|
||||||
|
default: '1.1.1.1'
|
||||||
|
},
|
||||||
|
// Holesail
|
||||||
|
'HOLESAIL_TIMEOUT': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Holesail',
|
||||||
|
label: 'Holesail Client Timeout (minutes)',
|
||||||
|
description: 'Timeout in minutes for non-persistent Holesail clients (default: 5 minutes)',
|
||||||
|
default: '5',
|
||||||
|
min: 1
|
||||||
|
},
|
||||||
|
'FULL_PERSISTENCE': {
|
||||||
|
type: 'checkbox',
|
||||||
|
category: 'Holesail',
|
||||||
|
label: 'Full Persistence',
|
||||||
|
description: 'Enable full persistence mode - prevents shutdown/restart of holesail clients/servers (default: false) [Not recommended for low ram devices]',
|
||||||
|
default: 'false'
|
||||||
|
},
|
||||||
|
'DISABLE_AUTO_SUBSCRIPTION': {
|
||||||
|
type: 'checkbox',
|
||||||
|
category: 'Holesail',
|
||||||
|
label: 'Disable Auto Subscription Service Starts',
|
||||||
|
description: 'Disable automatic creation of holesail clients for subscribed services on startup (default: false)',
|
||||||
|
default: 'false'
|
||||||
|
},
|
||||||
|
// Security & Access
|
||||||
|
'ALLOW_ANY_WRITER_INVITES': {
|
||||||
|
type: 'checkbox',
|
||||||
|
category: 'Security & Access',
|
||||||
|
label: 'Allow Any Writer Invites',
|
||||||
|
description: 'Allow non-master nodes to issue invites (default: true)',
|
||||||
|
default: 'true'
|
||||||
|
},
|
||||||
|
'DISABLE_DNS_SERVER': {
|
||||||
|
type: 'checkbox',
|
||||||
|
category: 'Security & Access',
|
||||||
|
label: 'Disable DNS Server',
|
||||||
|
description: 'Disable the DNS server (default: false)',
|
||||||
|
default: 'false'
|
||||||
|
},
|
||||||
|
'DISABLE_PROXY_SERVER': {
|
||||||
|
type: 'checkbox',
|
||||||
|
category: 'Security & Access',
|
||||||
|
label: 'Disable Proxy Server',
|
||||||
|
description: 'Disable HTTPS and HTTP proxy servers (default: false)',
|
||||||
|
default: 'false'
|
||||||
|
},
|
||||||
|
'DISABLE_VIRTUAL_INTERFACES': {
|
||||||
|
type: 'checkbox',
|
||||||
|
category: 'Security & Access',
|
||||||
|
label: 'Disable Virtual Interfaces',
|
||||||
|
description: 'Disable virtual interface creation (default: false)',
|
||||||
|
default: 'false'
|
||||||
|
},
|
||||||
|
// Logging & Debugging
|
||||||
|
'LOG_LEVEL': {
|
||||||
|
type: 'select',
|
||||||
|
category: 'Logging & Debugging',
|
||||||
|
label: 'Log Level',
|
||||||
|
description: 'Logging verbosity level (0=DEBUG, 1=INFO, 2=WARN, 3=ERROR)',
|
||||||
|
default: '0',
|
||||||
|
options: [
|
||||||
|
{ value: '0', label: 'DEBUG (0)' },
|
||||||
|
{ value: '1', label: 'INFO (1)' },
|
||||||
|
{ value: '2', label: 'WARN (2)' },
|
||||||
|
{ value: '3', label: 'ERROR (3)' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Performance
|
||||||
|
'RATE_LIMIT_MAX_REQUESTS': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Performance',
|
||||||
|
label: 'Rate Limit Max Requests',
|
||||||
|
description: 'Maximum requests per window for rate limiting (default: 100)',
|
||||||
|
default: '100',
|
||||||
|
min: 1
|
||||||
|
},
|
||||||
|
'RATE_LIMIT_WINDOW_MS': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Performance',
|
||||||
|
label: 'Rate Limit Window (seconds)',
|
||||||
|
description: 'Time window in seconds for rate limiting (default: 60 = 1 minute)',
|
||||||
|
default: '60',
|
||||||
|
min: 1
|
||||||
|
},
|
||||||
|
'DNS_POOL_SIZE': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Performance',
|
||||||
|
label: 'DNS Pool Size',
|
||||||
|
description: 'Maximum number of DNS resolver connections in pool (default: 5)',
|
||||||
|
default: '5',
|
||||||
|
min: 1,
|
||||||
|
max: 50
|
||||||
|
},
|
||||||
|
'PORT_CHECK_TIMEOUT': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Performance',
|
||||||
|
label: 'Port Check Timeout (seconds)',
|
||||||
|
description: 'Timeout in seconds for port availability checks (default: 2)',
|
||||||
|
default: '2',
|
||||||
|
min: 0.1
|
||||||
|
},
|
||||||
|
'METRICS_RETENTION_MS': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Performance',
|
||||||
|
label: 'Metrics Retention (minutes)',
|
||||||
|
description: 'Metrics data retention period in minutes (default: 60 = 1 hour)',
|
||||||
|
default: '60',
|
||||||
|
min: 1
|
||||||
|
},
|
||||||
|
'METRICS_SAMPLING_RATE': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Performance',
|
||||||
|
label: 'Metrics Sampling Rate',
|
||||||
|
description: 'Metrics sampling rate (0.0-1.0, default: 1.0 for all samples)',
|
||||||
|
default: '1.0',
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.0
|
||||||
|
},
|
||||||
|
'METRICS_AGGREGATION_INTERVAL': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Performance',
|
||||||
|
label: 'Metrics Aggregation Interval (seconds)',
|
||||||
|
description: 'Metrics aggregation interval in seconds (default: 60 = 1 minute)',
|
||||||
|
default: '60',
|
||||||
|
min: 1
|
||||||
|
},
|
||||||
|
'METRICS_MAX_BUFFER_SIZE': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Performance',
|
||||||
|
label: 'Metrics Max Buffer Size',
|
||||||
|
description: 'Maximum number of metric samples to buffer (default: 1000)',
|
||||||
|
default: '1000',
|
||||||
|
min: 100,
|
||||||
|
max: 10000
|
||||||
|
},
|
||||||
|
// Advanced
|
||||||
|
'TOPIC_SEED': {
|
||||||
|
type: 'text',
|
||||||
|
category: 'Advanced',
|
||||||
|
label: 'Topic Seed',
|
||||||
|
description: 'Seed for generating Hyperswarm discovery topic (default: p2ns-dns)',
|
||||||
|
default: 'p2ns-dns'
|
||||||
|
},
|
||||||
|
'SWARM_KEEPALIVE_INTERVAL': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Advanced',
|
||||||
|
label: 'Swarm Keep-Alive Interval (milliseconds)',
|
||||||
|
description: 'Interval in milliseconds to rejoin swarm topic to maintain DHT connection (default: 60000 = 60 seconds). Set to 0 to disable. This ensures nodes stay connected to the swarm network.',
|
||||||
|
default: '60000',
|
||||||
|
min: 0
|
||||||
|
},
|
||||||
|
'CHANNEL_KEEPALIVE_INTERVAL': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Advanced',
|
||||||
|
label: 'Channel Keep-Alive Interval (milliseconds)',
|
||||||
|
description: 'Interval in milliseconds to send heartbeat pings on plugin channels (default: 30000 = 30 seconds). Set to 0 to disable. Detects stale channels and triggers reconnection.',
|
||||||
|
default: '30000',
|
||||||
|
min: 0
|
||||||
|
},
|
||||||
|
'CHANNEL_KEEPALIVE_TIMEOUT': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Advanced',
|
||||||
|
label: 'Channel Keep-Alive Timeout (milliseconds)',
|
||||||
|
description: 'Time to wait for heartbeat pong before marking channel as stale (default: 10000 = 10 seconds). Channel will be recreated if no response is received.',
|
||||||
|
default: '10000',
|
||||||
|
min: 1000
|
||||||
|
},
|
||||||
|
// Consensus
|
||||||
|
'CONSENSUS_QUORUM_THRESHOLD': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Advanced',
|
||||||
|
label: 'Consensus Quorum Threshold',
|
||||||
|
description: 'Percentage of active peers that must vote to meet quorum (0.0-1.0, default: 0.5). For example, 0.5 means 50% of peers must vote.',
|
||||||
|
default: '0.5',
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.0
|
||||||
|
},
|
||||||
|
'CONSENSUS_MIN_VOTES': {
|
||||||
|
type: 'number',
|
||||||
|
category: 'Advanced',
|
||||||
|
label: 'Consensus Minimum Votes',
|
||||||
|
description: 'Minimum number of votes required regardless of peer count (default: 2). Ensures quorum even in small networks.',
|
||||||
|
default: '2',
|
||||||
|
min: 1
|
||||||
|
},
|
||||||
|
'CONSENSUS_TIE_BREAKER': {
|
||||||
|
type: 'select',
|
||||||
|
category: 'Advanced',
|
||||||
|
label: 'Consensus Tie Breaker',
|
||||||
|
description: 'Strategy for breaking ties between claimants with equal votes (default: timestamp). Options: timestamp (prefer oldest claim), claimant_age (prefer longest history), lexicographic (alphabetical ordering).',
|
||||||
|
default: 'timestamp',
|
||||||
|
options: [
|
||||||
|
{ value: 'timestamp', label: 'Timestamp (prefer oldest claim)' },
|
||||||
|
{ value: 'claimant_age', label: 'Claimant Age (prefer longest history)' },
|
||||||
|
{ value: 'lexicographic', label: 'Lexicographic (alphabetical ordering)' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'CONSENSUS_VOTE_VALIDATION': {
|
||||||
|
type: 'checkbox',
|
||||||
|
category: 'Advanced',
|
||||||
|
label: 'Consensus Vote Validation',
|
||||||
|
description: 'Whether to validate that votes reference existing claims (default: true). Invalid votes are ignored when enabled.',
|
||||||
|
default: 'true'
|
||||||
|
},
|
||||||
|
'CONSENSUS_IMMEDIATE_UPDATE': {
|
||||||
|
type: 'checkbox',
|
||||||
|
category: 'Advanced',
|
||||||
|
label: 'Consensus Immediate Update',
|
||||||
|
description: 'Whether to trigger consensus recalculation immediately when claims or votes change (default: true). When false, waits for next periodic check.',
|
||||||
|
default: 'true'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
settingsMetadata,
|
||||||
|
restartRequiredSettings,
|
||||||
|
liveReloadableSettings,
|
||||||
|
envWhitelist,
|
||||||
|
applyLiveSettings
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
const WebSocket = require('ws');
|
||||||
|
const { logDebug, logError, logInfo, logWarn } = require('../../infrastructure/logger');
|
||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const { metrics } = require('../../maintenance/metrics');
|
||||||
|
|
||||||
|
const adminWss = new WebSocket.Server({ noServer: true });
|
||||||
|
const adminClients = new Set();
|
||||||
|
let healthBroadcastInterval = null;
|
||||||
|
|
||||||
|
adminWss.on('connection', (ws) => {
|
||||||
|
logDebug('Admin', 'WebSocket client connected');
|
||||||
|
adminClients.add(ws);
|
||||||
|
|
||||||
|
// Handle close event
|
||||||
|
ws.on('close', () => {
|
||||||
|
adminClients.delete(ws);
|
||||||
|
logDebug('Admin', 'WebSocket client disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle error event to ensure cleanup
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
logError('Admin', `WebSocket error: ${err.message}`);
|
||||||
|
adminClients.delete(ws);
|
||||||
|
try {
|
||||||
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
} catch (closeErr) {
|
||||||
|
logDebug('Admin', `Error closing WebSocket after error: ${closeErr.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle unexpected termination
|
||||||
|
ws.on('unexpected-response', () => {
|
||||||
|
logWarn('Admin', 'WebSocket received unexpected response');
|
||||||
|
adminClients.delete(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function broadcast(msg) {
|
||||||
|
for (const client of adminClients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllWebSockets() {
|
||||||
|
logDebug('Admin', `Closing ${adminClients.size} WebSocket connections...`);
|
||||||
|
for (const client of adminClients) {
|
||||||
|
try {
|
||||||
|
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error closing WebSocket client: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adminClients.clear();
|
||||||
|
|
||||||
|
// Stop health broadcasts
|
||||||
|
stopHealthBroadcasts();
|
||||||
|
|
||||||
|
// Close the WebSocket server
|
||||||
|
try {
|
||||||
|
adminWss.close();
|
||||||
|
logInfo('Admin', 'WebSocket server closed');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error closing WebSocket server: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start periodic health broadcasts
|
||||||
|
function startHealthBroadcasts() {
|
||||||
|
if (healthBroadcastInterval) return;
|
||||||
|
|
||||||
|
// Broadcast immediately
|
||||||
|
broadcastHealth();
|
||||||
|
|
||||||
|
// Then every 5 seconds
|
||||||
|
healthBroadcastInterval = setInterval(() => {
|
||||||
|
broadcastHealth();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
logDebug('Admin', 'Health broadcasts started');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop health broadcasts
|
||||||
|
function stopHealthBroadcasts() {
|
||||||
|
if (healthBroadcastInterval) {
|
||||||
|
clearInterval(healthBroadcastInterval);
|
||||||
|
healthBroadcastInterval = null;
|
||||||
|
logDebug('Admin', 'Health broadcasts stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast health status
|
||||||
|
function broadcastHealth() {
|
||||||
|
if (adminClients.size === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dnsHealthy = !!state.dnsPass && state.dnsPass.ready;
|
||||||
|
const proxyHealthy = process.env.DISABLE_PROXY_SERVER !== 'true';
|
||||||
|
const swarmHealthy = state.connectedPeers !== undefined;
|
||||||
|
const corestoreHealthy = state.dnsPass && state.dnsPass.base && state.dnsPass.base.writable !== undefined;
|
||||||
|
const allServicesHealthy = dnsHealthy && proxyHealthy && swarmHealthy && corestoreHealthy;
|
||||||
|
|
||||||
|
const health = {
|
||||||
|
type: 'update-health',
|
||||||
|
status: allServicesHealthy ? 'healthy' : 'degraded',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: Date.now() - (metrics?.startTime || Date.now()),
|
||||||
|
services: {
|
||||||
|
dns: { healthy: dnsHealthy, enabled: process.env.DISABLE_DNS_SERVER !== 'true' },
|
||||||
|
proxy: { healthy: proxyHealthy, enabled: process.env.DISABLE_PROXY_SERVER !== 'true' },
|
||||||
|
swarm: { healthy: swarmHealthy },
|
||||||
|
corestore: { healthy: corestoreHealthy }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcast(health);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Error broadcasting health: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start health broadcasts when module loads
|
||||||
|
startHealthBroadcasts();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
adminWss,
|
||||||
|
adminClients,
|
||||||
|
broadcast,
|
||||||
|
closeAllWebSockets,
|
||||||
|
startHealthBroadcasts,
|
||||||
|
stopHealthBroadcasts
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// Main admin entry point - loads all modules and initializes the application
|
||||||
|
// Load order: config -> state -> utils -> core -> notifications -> ws-client -> ui modules -> main init
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
function initializeApp() {
|
||||||
|
// showTab function - must be defined after all modules are loaded
|
||||||
|
function showTab(tabId) {
|
||||||
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||||
|
const tabEl = document.getElementById(tabId);
|
||||||
|
if (tabEl) tabEl.classList.remove('hidden');
|
||||||
|
window.activeTab = tabId;
|
||||||
|
|
||||||
|
// Enable/disable body scrolling based on tab
|
||||||
|
const scrollableTabs = ['stats', 'plugins', 'settings'];
|
||||||
|
if (scrollableTabs.includes(tabId)) {
|
||||||
|
document.body.classList.remove('no-scroll');
|
||||||
|
} else {
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.tabs && window.tabs[tabId] && window.genericFetch) {
|
||||||
|
if (tabId === 'backups' && window.renderBackups) {
|
||||||
|
window.renderBackups();
|
||||||
|
} else if (tabId === 'peers' && window.renderPeers) {
|
||||||
|
window.renderPeers();
|
||||||
|
} else {
|
||||||
|
window.genericFetch(tabId, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tabId === 'host') {
|
||||||
|
// Show servers sub-tab by default
|
||||||
|
if (window.showSubTab) {
|
||||||
|
window.showSubTab('host', 'servers');
|
||||||
|
}
|
||||||
|
if (!window.wsConnected) {
|
||||||
|
if (window.startPollingFallback) window.startPollingFallback();
|
||||||
|
} else {
|
||||||
|
if (window.stopPollingFallback) window.stopPollingFallback();
|
||||||
|
}
|
||||||
|
} else if (tabId === 'local-dns') {
|
||||||
|
// Show records sub-tab by default
|
||||||
|
if (window.showSubTab) {
|
||||||
|
window.showSubTab('local-dns', 'records');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.stopPollingFallback) window.stopPollingFallback();
|
||||||
|
}
|
||||||
|
if (tabId === 'logs') {
|
||||||
|
if (window.renderLogs) window.renderLogs();
|
||||||
|
}
|
||||||
|
if (tabId === 'stats') {
|
||||||
|
if (window.renderStats) window.renderStats();
|
||||||
|
if (window.renderHealth) window.renderHealth();
|
||||||
|
if (window.renderDiagnostics) window.renderDiagnostics();
|
||||||
|
if (!window.statsUpdateInterval && window.startStatsUpdates) {
|
||||||
|
window.startStatsUpdates();
|
||||||
|
}
|
||||||
|
if (!window.healthUpdateInterval && window.startHealthUpdates) {
|
||||||
|
window.startHealthUpdates();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.stopStatsUpdates) window.stopStatsUpdates();
|
||||||
|
if (window.stopHealthUpdates) window.stopHealthUpdates();
|
||||||
|
}
|
||||||
|
if (tabId === 'settings') {
|
||||||
|
if (window.renderSettings) {
|
||||||
|
// Fetch settings and render
|
||||||
|
fetch('/api/settings')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(async data => {
|
||||||
|
// Set metadata for renderSettings to use
|
||||||
|
if (data.metadata) {
|
||||||
|
window.settingsMetadata = data.metadata;
|
||||||
|
}
|
||||||
|
if (window.renderSettings) await window.renderSettings(data);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to fetch settings:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to load settings', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tabId === 'plugins') {
|
||||||
|
if (window.renderPlugins) {
|
||||||
|
window.renderPlugins();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clean up plugin terminals when leaving plugins tab
|
||||||
|
if (window.pluginTerminals) {
|
||||||
|
window.pluginTerminals.forEach((term, domain) => {
|
||||||
|
if (window.cleanupPluginTerminal) {
|
||||||
|
window.cleanupPluginTerminal(domain);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Note: fetchSubnets() is now called from renderSettings() after the subnet configurator HTML is created
|
||||||
|
// This ensures the DOM elements exist before attempting to populate them
|
||||||
|
}
|
||||||
|
window.showTab = showTab;
|
||||||
|
|
||||||
|
// Show sub-tab function for tabs with sub-sections
|
||||||
|
function showSubTab(mainTabId, subTabId) {
|
||||||
|
// Hide all sub-tabs in this main tab
|
||||||
|
const mainTab = document.getElementById(mainTabId);
|
||||||
|
if (!mainTab) return;
|
||||||
|
|
||||||
|
mainTab.querySelectorAll('.sub-tab-content').forEach(el => el.classList.add('hidden'));
|
||||||
|
|
||||||
|
// Show the selected sub-tab
|
||||||
|
const subTab = document.getElementById(`${mainTabId}-${subTabId}`);
|
||||||
|
if (subTab) {
|
||||||
|
subTab.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update button styles
|
||||||
|
mainTab.querySelectorAll(`[id^="${mainTabId}-subtab-"]`).forEach(btn => {
|
||||||
|
if (btn.id === `${mainTabId}-subtab-${subTabId}`) {
|
||||||
|
btn.className = 'px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover';
|
||||||
|
} else {
|
||||||
|
btn.className = 'px-4 py-2 theme-button-info rounded transition-colors';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch data for the sub-tab if needed
|
||||||
|
if (mainTabId === 'host') {
|
||||||
|
if (subTabId === 'servers' && window.genericFetch) {
|
||||||
|
window.genericFetch('host-servers', true);
|
||||||
|
} else if (subTabId === 'clients' && window.genericFetch) {
|
||||||
|
window.genericFetch('host-clients', true);
|
||||||
|
}
|
||||||
|
} else if (mainTabId === 'local-dns') {
|
||||||
|
if (subTabId === 'records' && window.genericFetch) {
|
||||||
|
window.genericFetch('local-dns', true);
|
||||||
|
} else if (subTabId === 'conflicts' && window.genericFetch) {
|
||||||
|
window.genericFetch('dns-conflicts', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.showSubTab = showSubTab;
|
||||||
|
|
||||||
|
// Filter settings function
|
||||||
|
function filterSettings() {
|
||||||
|
const query = document.getElementById('search-settings')?.value.toLowerCase() || '';
|
||||||
|
const container = document.getElementById('settingsContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const categories = container.querySelectorAll('.settings-category');
|
||||||
|
categories.forEach(category => {
|
||||||
|
const categoryTitle = category.querySelector('h3')?.textContent.toLowerCase() || '';
|
||||||
|
const items = category.querySelectorAll('.settings-item');
|
||||||
|
let categoryVisible = categoryTitle.includes(query);
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const label = item.querySelector('label')?.textContent.toLowerCase() || '';
|
||||||
|
const description = item.querySelector('.settings-description')?.textContent.toLowerCase() || '';
|
||||||
|
const matches = label.includes(query) || description.includes(query);
|
||||||
|
item.style.display = matches ? '' : 'none';
|
||||||
|
if (matches) categoryVisible = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
category.style.display = categoryVisible ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.filterSettings = filterSettings;
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
const hash = location.hash.substring(1);
|
||||||
|
const tabId = hash && document.getElementById(hash) ? hash : 'domains';
|
||||||
|
|
||||||
|
// Set initial scroll state
|
||||||
|
const scrollableTabs = ['stats', 'plugins', 'settings'];
|
||||||
|
if (!scrollableTabs.includes(tabId)) {
|
||||||
|
document.body.classList.add('no-scroll');
|
||||||
|
}
|
||||||
|
|
||||||
|
showTab(tabId);
|
||||||
|
|
||||||
|
if (window.startStatusUpdates) window.startStatusUpdates();
|
||||||
|
|
||||||
|
// Pre-load local-dns data so it's available when the tab is accessed
|
||||||
|
if (window.genericFetch) {
|
||||||
|
window.genericFetch('local-dns', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup stats auto-refresh event listeners
|
||||||
|
const autoRefreshCheckbox = document.getElementById('auto-refresh-stats');
|
||||||
|
if (autoRefreshCheckbox) {
|
||||||
|
autoRefreshCheckbox.addEventListener('change', (e) => {
|
||||||
|
if (e.target.checked && window.activeTab === 'stats') {
|
||||||
|
if (window.startStatsUpdates) window.startStatsUpdates();
|
||||||
|
} else {
|
||||||
|
if (window.stopStatsUpdates) window.stopStatsUpdates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshIntervalSelector = document.getElementById('refresh-interval-selector');
|
||||||
|
if (refreshIntervalSelector) {
|
||||||
|
refreshIntervalSelector.addEventListener('change', () => {
|
||||||
|
if (window.activeTab === 'stats' && autoRefreshCheckbox && autoRefreshCheckbox.checked) {
|
||||||
|
if (window.startStatsUpdates) window.startStatsUpdates(); // Restart with new interval
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeRangeSelector = document.getElementById('time-range-selector');
|
||||||
|
if (timeRangeSelector) {
|
||||||
|
timeRangeSelector.addEventListener('change', () => {
|
||||||
|
if (window.activeTab === 'stats') {
|
||||||
|
if (window.renderStats) window.renderStats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeApp);
|
||||||
|
} else {
|
||||||
|
initializeApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle hash changes
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
const tabId = location.hash.substring(1);
|
||||||
|
if (tabId && document.getElementById(tabId) && window.showTab) {
|
||||||
|
window.showTab(tabId);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// Backups UI functions
|
||||||
|
|
||||||
|
let backupsData = [];
|
||||||
|
let filteredBackups = [];
|
||||||
|
|
||||||
|
// Fetch backups from API
|
||||||
|
async function fetchBackups() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backups');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch backups');
|
||||||
|
}
|
||||||
|
backupsData = await response.json();
|
||||||
|
filteredBackups = backupsData;
|
||||||
|
return backupsData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch backups:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to load backups', 'error');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render backups list
|
||||||
|
async function renderBackups() {
|
||||||
|
await fetchBackups();
|
||||||
|
filterBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter backups
|
||||||
|
function filterBackups() {
|
||||||
|
const searchEl = document.getElementById('search-backups');
|
||||||
|
if (!searchEl) return;
|
||||||
|
const query = searchEl.value.toLowerCase();
|
||||||
|
filteredBackups = backupsData.filter(backup =>
|
||||||
|
backup.name.toLowerCase().includes(query) ||
|
||||||
|
backup.timestamp.toLowerCase().includes(query) ||
|
||||||
|
(backup.version && backup.version.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset infinite scroll state when filtering
|
||||||
|
if (!window.infiniteScrollState) {
|
||||||
|
window.infiniteScrollState = {};
|
||||||
|
}
|
||||||
|
if (window.infiniteScrollState.backups) {
|
||||||
|
window.infiniteScrollState.backups.loadedCount = 0;
|
||||||
|
window.infiniteScrollState.backups.lastQuery = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBackupsTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render backups table with infinite scroll
|
||||||
|
function renderBackupsTable() {
|
||||||
|
const container = document.getElementById('backupsTable');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Initialize infinite scroll state
|
||||||
|
if (!window.infiniteScrollState) {
|
||||||
|
window.infiniteScrollState = {};
|
||||||
|
}
|
||||||
|
if (!window.infiniteScrollState.backups) {
|
||||||
|
window.infiniteScrollState.backups = {
|
||||||
|
loadedCount: 0,
|
||||||
|
observer: null,
|
||||||
|
batchSize: 15,
|
||||||
|
lastQuery: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = window.infiniteScrollState.backups;
|
||||||
|
const searchEl = document.getElementById('search-backups');
|
||||||
|
const query = searchEl ? searchEl.value.toLowerCase() : '';
|
||||||
|
const isNewSearch = state.lastQuery !== query;
|
||||||
|
|
||||||
|
// Reset if new search
|
||||||
|
if (isNewSearch) {
|
||||||
|
state.loadedCount = 0;
|
||||||
|
state.lastQuery = query;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Disconnect existing observer
|
||||||
|
if (state.observer) {
|
||||||
|
state.observer.disconnect();
|
||||||
|
state.observer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty state
|
||||||
|
if (filteredBackups.length === 0) {
|
||||||
|
container.innerHTML = '<tr><td colspan="6" class="p-4 text-center theme-text-tertiary">No backups found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load next batch
|
||||||
|
loadBackupsBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load next batch of backups
|
||||||
|
function loadBackupsBatch() {
|
||||||
|
const container = document.getElementById('backupsTable');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const state = window.infiniteScrollState.backups;
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
const start = state.loadedCount;
|
||||||
|
const end = Math.min(start + state.batchSize, filteredBackups.length);
|
||||||
|
const batch = filteredBackups.slice(start, end);
|
||||||
|
|
||||||
|
if (batch.length === 0) {
|
||||||
|
// No more data to load
|
||||||
|
if (state.observer) {
|
||||||
|
state.observer.disconnect();
|
||||||
|
state.observer = null;
|
||||||
|
}
|
||||||
|
// Remove sentinel if exists
|
||||||
|
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||||
|
if (sentinel) sentinel.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render batch
|
||||||
|
batch.forEach(backup => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||||
|
const date = new Date(backup.timestamp);
|
||||||
|
const dateStr = date.toLocaleString();
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="p-3">${backup.name}</td>
|
||||||
|
<td class="p-3">${dateStr}</td>
|
||||||
|
<td class="p-3">${backup.sizeFormatted || '0 B'}</td>
|
||||||
|
<td class="p-3">${backup.files ? backup.files.length : 0}</td>
|
||||||
|
<td class="p-3">${backup.version || 'unknown'}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<button onclick="viewBackupDetails('${backup.name}')" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 mr-2">Details</button>
|
||||||
|
<button onclick="restoreBackup('${backup.name}')" class="px-2 py-1 bg-green-500 text-white rounded hover:bg-green-600 mr-2">Restore</button>
|
||||||
|
<button onclick="deleteBackup('${backup.name}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
container.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.loadedCount = end;
|
||||||
|
|
||||||
|
// Setup IntersectionObserver for next batch
|
||||||
|
if (end < filteredBackups.length) {
|
||||||
|
setupBackupsObserver(container);
|
||||||
|
} else {
|
||||||
|
// All data loaded, disconnect observer
|
||||||
|
if (state.observer) {
|
||||||
|
state.observer.disconnect();
|
||||||
|
state.observer = null;
|
||||||
|
}
|
||||||
|
// Remove sentinel if exists
|
||||||
|
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||||
|
if (sentinel) sentinel.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup IntersectionObserver for backups
|
||||||
|
function setupBackupsObserver(container) {
|
||||||
|
const state = window.infiniteScrollState.backups;
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
// Create or get sentinel element
|
||||||
|
let sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||||
|
if (!sentinel) {
|
||||||
|
sentinel = document.createElement('tr');
|
||||||
|
sentinel.className = 'infinite-scroll-sentinel';
|
||||||
|
sentinel.innerHTML = '<td colspan="6" style="height: 1px; padding: 0;"></td>';
|
||||||
|
container.appendChild(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the scrollable container (must be a parent with overflow-y-auto or overflow-auto)
|
||||||
|
const scrollContainer = container.closest('.overflow-y-auto, .overflow-auto');
|
||||||
|
|
||||||
|
if (!scrollContainer) {
|
||||||
|
console.warn('No scrollable container found for backups');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect existing observer
|
||||||
|
if (state.observer) {
|
||||||
|
state.observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
loadBackupsBatch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
root: scrollContainer,
|
||||||
|
rootMargin: '200px'
|
||||||
|
});
|
||||||
|
|
||||||
|
state.observer.observe(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination function removed - using infinite scroll instead
|
||||||
|
|
||||||
|
// Create backup
|
||||||
|
async function createBackup() {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm('Create a new backup? This may take a moment.', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backups/create', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to create backup');
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Backup created successfully');
|
||||||
|
await renderBackups();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create backup:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to create backup: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore backup
|
||||||
|
async function restoreBackup(backupName) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Restore from backup "${backupName}"? This will create a backup of current state first, then restore.`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backups/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ backupName })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to restore backup');
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Backup restored successfully. System may need to refresh.', 'success');
|
||||||
|
await renderBackups();
|
||||||
|
// Optionally reload after restore
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm('Reload page to see restored data?', () => {
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to restore backup:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to restore backup: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete backup
|
||||||
|
async function deleteBackup(backupName) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Delete backup "${backupName}"? This action cannot be undone.`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/backups/${encodeURIComponent(backupName)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to delete backup');
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Backup deleted successfully');
|
||||||
|
|
||||||
|
// Re-fetch and re-render backups
|
||||||
|
await renderBackups();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete backup:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to delete backup: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View backup details
|
||||||
|
async function viewBackupDetails(backupName) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/backups/${encodeURIComponent(backupName)}/metadata`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch backup details');
|
||||||
|
}
|
||||||
|
const metadata = await response.json();
|
||||||
|
|
||||||
|
const modal = document.getElementById('backupDetailsModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const content = document.getElementById('backup-details-content');
|
||||||
|
if (content) {
|
||||||
|
const filesList = metadata.files.map(file =>
|
||||||
|
`<div class="mb-2 p-2 bg-gray-100 dark:bg-gray-700 rounded">
|
||||||
|
<div class="font-semibold">${file.name}</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Size: ${file.sizeFormatted} | Modified: ${file.modified ? new Date(file.modified).toLocaleString() : 'N/A'}</div>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="font-semibold mb-2">Backup Information</h4>
|
||||||
|
<p><strong>Name:</strong> ${metadata.name || backupName}</p>
|
||||||
|
<p><strong>Timestamp:</strong> ${new Date(metadata.timestamp).toLocaleString()}</p>
|
||||||
|
<p><strong>Version:</strong> ${metadata.version || 'unknown'}</p>
|
||||||
|
<p><strong>Files:</strong> ${metadata.files ? metadata.files.length : 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Files in Backup</h4>
|
||||||
|
${filesList || '<p class="text-gray-500">No files found</p>'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.showModal();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch backup details:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to load backup details: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.renderBackups = renderBackups;
|
||||||
|
window.createBackup = createBackup;
|
||||||
|
window.restoreBackup = restoreBackup;
|
||||||
|
window.deleteBackup = deleteBackup;
|
||||||
|
window.viewBackupDetails = viewBackupDetails;
|
||||||
|
window.filterBackups = filterBackups;
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Certificates UI functions
|
||||||
|
function regenerateCA() {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm('Regenerate Root CA?', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/regenerate-ca', { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Root CA regenerated successfully');
|
||||||
|
if (window.genericFetch) window.genericFetch('certs', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to regenerate CA:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to regenerate CA: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installCA() {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm('Install Root CA?', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/install-ca', { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Root CA installed successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to install CA:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to install CA: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCert() {
|
||||||
|
const domainEl = document.getElementById('cert-domain');
|
||||||
|
if (!domainEl) return;
|
||||||
|
const domain = domainEl.value;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/generate-cert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Certificate generated successfully');
|
||||||
|
if (window.genericFetch) window.genericFetch('certs', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate cert:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to generate cert: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCert(domain) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Delete certificate for ${domain}?`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/delete-cert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Certificate deleted successfully');
|
||||||
|
if (window.genericFetch) window.genericFetch('certs', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete cert:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to delete cert: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateCert(domain) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Regenerate certificate for ${domain}?`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/regenerate-cert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Certificate regenerated successfully');
|
||||||
|
if (window.genericFetch) window.genericFetch('certs', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to regenerate cert:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to regenerate cert: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showCertDetails(domain) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/cert-details?domain=${encodeURIComponent(domain)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
const data = await res.text();
|
||||||
|
const formatted = formatCertificate(data);
|
||||||
|
const contentEl = document.getElementById('cert-details-content');
|
||||||
|
const modal = document.getElementById('certDetailsModal');
|
||||||
|
if (contentEl) contentEl.textContent = formatted;
|
||||||
|
if (modal) modal.showModal();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch cert details:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to load certificate details: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCertificate(certPem) {
|
||||||
|
return certPem.replace(/(-----BEGIN CERTIFICATE-----)/g, '\n$1\n')
|
||||||
|
.replace(/(-----END CERTIFICATE-----)/g, '\n$1\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCertDetails() {
|
||||||
|
const contentEl = document.getElementById('cert-details-content');
|
||||||
|
if (!contentEl) return;
|
||||||
|
const content = contentEl.textContent;
|
||||||
|
navigator.clipboard.writeText(content).then(() => {
|
||||||
|
if (window.showNotification) window.showNotification('Certificate details copied to clipboard', 'success');
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to copy certificate details', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.regenerateCA = regenerateCA;
|
||||||
|
window.installCA = installCA;
|
||||||
|
window.generateCert = generateCert;
|
||||||
|
window.deleteCert = deleteCert;
|
||||||
|
window.regenerateCert = regenerateCert;
|
||||||
|
window.showCertDetails = showCertDetails;
|
||||||
|
window.formatCertificate = formatCertificate;
|
||||||
|
window.copyCertDetails = copyCertDetails;
|
||||||
|
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
// Configuration constants
|
||||||
|
window.updateMap = {
|
||||||
|
'update-database': ['domains', 'entries'],
|
||||||
|
'update-peers': ['peers'],
|
||||||
|
'update-certs': ['certs'],
|
||||||
|
'update-interfaces': ['interfaces'],
|
||||||
|
'update-local-dns': ['local-dns', 'dns-conflicts'],
|
||||||
|
'update-holesail': [],
|
||||||
|
'update-holesail-clients': [],
|
||||||
|
'update-settings': ['settings'],
|
||||||
|
'update-stats': [],
|
||||||
|
'system-reset': () => location.reload()
|
||||||
|
};
|
||||||
|
|
||||||
|
window.paginationState = {
|
||||||
|
domains: { current: 1, size: 8 },
|
||||||
|
entries: { current: 1, size: 10 },
|
||||||
|
peers: { current: 1, size: 4 },
|
||||||
|
certs: { current: 1, size: 5 },
|
||||||
|
interfaces: { current: 1, size: 10 },
|
||||||
|
'local-dns': { current: 1, size: 10 },
|
||||||
|
'dns-conflicts': { current: 1, size: 10 },
|
||||||
|
'host-servers': { current: 1, size: 10 },
|
||||||
|
'host-clients': { current: 1, size: 10 },
|
||||||
|
settings: { current: 1, size: 20 },
|
||||||
|
backups: { current: 1, size: 10 },
|
||||||
|
plugins: { current: 1, size: 10 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get consensus status badge
|
||||||
|
function getConsensusStatusBadge(status) {
|
||||||
|
const badges = {
|
||||||
|
'resolved': '<span class="px-2 py-1 bg-green-500 text-white rounded text-xs">Resolved</span>',
|
||||||
|
'tie': '<span class="px-2 py-1 bg-yellow-500 text-white rounded text-xs">Tie</span>',
|
||||||
|
'insufficient_quorum': '<span class="px-2 py-1 bg-orange-500 text-white rounded text-xs">No Quorum</span>',
|
||||||
|
'no_claims': '<span class="px-2 py-1 bg-gray-500 text-white rounded text-xs">No Claims</span>',
|
||||||
|
'error': '<span class="px-2 py-1 bg-red-500 text-white rounded text-xs">Error</span>',
|
||||||
|
'internal': '<span class="px-2 py-1 bg-blue-500 text-white rounded text-xs">Internal</span>',
|
||||||
|
'unknown': '<span class="px-2 py-1 bg-gray-400 text-white rounded text-xs">Unknown</span>'
|
||||||
|
};
|
||||||
|
return badges[status] || badges['unknown'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make function globally available
|
||||||
|
window.getConsensusStatusBadge = getConsensusStatusBadge;
|
||||||
|
|
||||||
|
window.chartColors = {
|
||||||
|
primary: 'rgb(59, 130, 246)',
|
||||||
|
success: 'rgb(34, 197, 94)',
|
||||||
|
warning: 'rgb(234, 179, 8)',
|
||||||
|
danger: 'rgb(239, 68, 68)',
|
||||||
|
info: 'rgb(59, 130, 246)',
|
||||||
|
gray: 'rgb(107, 114, 128)',
|
||||||
|
dark: 'rgb(17, 24, 39)'
|
||||||
|
};
|
||||||
|
|
||||||
|
window.darkModeColors = {
|
||||||
|
primary: 'rgb(96, 165, 250)',
|
||||||
|
success: 'rgb(74, 222, 128)',
|
||||||
|
warning: 'rgb(250, 204, 21)',
|
||||||
|
danger: 'rgb(248, 113, 113)',
|
||||||
|
info: 'rgb(96, 165, 250)',
|
||||||
|
gray: 'rgb(156, 163, 175)',
|
||||||
|
dark: 'rgb(243, 244, 246)'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tabs configuration - uses functions from utils.js and other modules
|
||||||
|
window.tabs = {
|
||||||
|
domains: {
|
||||||
|
api: '/api/resolved-domains',
|
||||||
|
searchId: 'search-domains',
|
||||||
|
dataKey: 'domainsData',
|
||||||
|
filteredKey: 'filteredDomains',
|
||||||
|
containerId: 'domainsTable',
|
||||||
|
paginationId: 'domainsPagination',
|
||||||
|
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
|
||||||
|
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.hash.toLowerCase().includes(query) || (item.consensusStatus || '').toLowerCase().includes(query),
|
||||||
|
renderItem: (item) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||||
|
|
||||||
|
// Use consensus state from postFetch if available
|
||||||
|
let consensusInfo = '';
|
||||||
|
if (item.consensusState) {
|
||||||
|
const consensusState = item.consensusState;
|
||||||
|
const statusBadge = getConsensusStatusBadge(consensusState.status);
|
||||||
|
const voteInfo = Object.keys(consensusState.voteCounts || {}).length > 0
|
||||||
|
? ` (${Object.values(consensusState.voteCounts).reduce((a, b) => a + b, 0)} votes)`
|
||||||
|
: '';
|
||||||
|
const quorumInfo = consensusState.quorumMet ? '✓' : '✗';
|
||||||
|
consensusInfo = `<td class="p-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>${statusBadge}</span>
|
||||||
|
<span class="text-xs" style="color: var(--text-primary);">${voteInfo}</span>
|
||||||
|
<span class="text-xs" style="color: var(--text-primary);" title="Quorum: ${consensusState.quorumMet ? 'Met' : 'Not Met'}">${quorumInfo}</span>
|
||||||
|
</div>
|
||||||
|
</td>`;
|
||||||
|
} else if (item.consensusStatus === 'internal') {
|
||||||
|
consensusInfo = `<td class="p-3">${getConsensusStatusBadge('internal')}</td>`;
|
||||||
|
} else {
|
||||||
|
consensusInfo = `<td class="p-3">${getConsensusStatusBadge('unknown')}</td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.innerHTML = `<td class="p-3">${item.domain}${item.isLocal ? '🏠' : ''}</td>
|
||||||
|
<td class="p-3 break-all">${item.hash}</td>
|
||||||
|
${consensusInfo}
|
||||||
|
<td class="p-3">${item.isLocal && item.hash !== 'internal' ? `<button onclick="removeDomain('${item.domain}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Remove</button>` : ''}</td>`;
|
||||||
|
return tr;
|
||||||
|
},
|
||||||
|
postFetch: async (data) => {
|
||||||
|
// Fetch consensus states for all domains
|
||||||
|
const domainsWithConsensus = await Promise.all(data.map(async (item) => {
|
||||||
|
if (item.hash === 'internal' || item.hash === 'none') {
|
||||||
|
return { ...item, consensusStatus: 'internal' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const consensusRes = await fetch(`/api/consensus/${encodeURIComponent(item.domain)}`);
|
||||||
|
if (consensusRes.ok) {
|
||||||
|
const consensusState = await consensusRes.json();
|
||||||
|
return { ...item, consensusStatus: consensusState.status, consensusState };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch consensus for ${item.domain}:`, err);
|
||||||
|
}
|
||||||
|
return { ...item, consensusStatus: 'unknown' };
|
||||||
|
}));
|
||||||
|
return domainsWithConsensus;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
api: '/api/entries',
|
||||||
|
searchId: 'search-entries',
|
||||||
|
dataKey: 'entriesData',
|
||||||
|
filteredKey: 'filteredEntries',
|
||||||
|
containerId: 'entriesTable',
|
||||||
|
paginationId: 'entriesPagination',
|
||||||
|
sentinelId: 'entriesScrollSentinel',
|
||||||
|
countId: 'entries-count',
|
||||||
|
useLazyScroll: true,
|
||||||
|
sort: (a, b) => a.key.localeCompare(b.key, undefined, { sensitivity: 'base' }),
|
||||||
|
filter: (item, query) => item.key.toLowerCase().includes(query) || item.value.toLowerCase().includes(query),
|
||||||
|
renderItem: (item) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||||
|
tr.innerHTML = `<td class="p-3 break-all">${item.key}</td>
|
||||||
|
<td class="p-3 break-all">${item.value}</td>`;
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
peers: {
|
||||||
|
api: '/api/peers',
|
||||||
|
searchId: 'search-peers',
|
||||||
|
dataKey: 'peersData',
|
||||||
|
filteredKey: 'filteredPeers',
|
||||||
|
containerId: 'peersList',
|
||||||
|
paginationId: 'peersPagination',
|
||||||
|
sort: (a, b) => (a.id || '').localeCompare(b.id || '', undefined, { sensitivity: 'base' }),
|
||||||
|
filter: (item, query) => {
|
||||||
|
const queryLower = query.toLowerCase();
|
||||||
|
return (item.id || '').toLowerCase().includes(queryLower) ||
|
||||||
|
(item.connected ? 'connected' : 'disconnected').includes(queryLower);
|
||||||
|
},
|
||||||
|
renderItem: (peer) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow';
|
||||||
|
|
||||||
|
const uptime = peer.uptime ? (window.formatUptime ? window.formatUptime(peer.uptime) : `${Math.floor(peer.uptime / 1000)}s`) : 'N/A';
|
||||||
|
const statusBadge = peer.connected
|
||||||
|
? '<span class="px-2 py-1 bg-green-500 rounded text-sm" style="color: var(--text-primary);">Connected</span>'
|
||||||
|
: '<span class="px-2 py-1 bg-gray-500 rounded text-sm" style="color: var(--text-primary);">Disconnected</span>';
|
||||||
|
const blockedBadge = peer.isBlocked
|
||||||
|
? '<span class="px-2 py-1 bg-red-500 rounded text-sm ml-2" style="color: var(--text-primary);">Blocked</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span class="font-mono text-sm break-all cursor-pointer text-blue-500 hover:underline" onclick="showPeerDetails('${peer.id}')">${peer.id}</span>
|
||||||
|
${statusBadge}
|
||||||
|
${blockedBadge}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm theme-text-secondary">
|
||||||
|
<div>Uptime: ${uptime}</div>
|
||||||
|
<div>Connections: ${peer.metrics?.connections || 0} | Avg Duration: ${peer.metrics?.avgDuration ? window.formatDuration ? window.formatDuration(peer.metrics.avgDuration) : `${Math.floor(peer.metrics.avgDuration / 1000)}s` : 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4 flex-shrink-0">
|
||||||
|
<button onclick="showPeerDetails('${peer.id}')" class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm whitespace-nowrap">Details</button>
|
||||||
|
${peer.isBlocked
|
||||||
|
? `<button onclick="unblockPeer('${peer.id}')" class="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 text-sm whitespace-nowrap">Unblock</button>`
|
||||||
|
: `<button onclick="blockPeer('${peer.id}')" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 text-sm whitespace-nowrap">Block</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return li;
|
||||||
|
},
|
||||||
|
preRender: (total) => {
|
||||||
|
const el = document.getElementById('peers-count');
|
||||||
|
if (el) el.textContent = `(${total})`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
certs: {
|
||||||
|
api: '/api/certs',
|
||||||
|
searchId: 'search-certs',
|
||||||
|
dataKey: 'certsData',
|
||||||
|
filteredKey: 'filteredCerts',
|
||||||
|
containerId: 'certsList',
|
||||||
|
paginationId: 'certsPagination',
|
||||||
|
sort: (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }),
|
||||||
|
filter: (item, query) => item.toLowerCase().includes(query),
|
||||||
|
renderItem: (cert) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow flex justify-between items-center';
|
||||||
|
li.innerHTML = `<span class="cursor-pointer flex-1 break-all" onclick="showCertDetails('${cert}')">${cert}</span>
|
||||||
|
<div>
|
||||||
|
<button onclick="deleteCert('${cert}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 mr-2">Delete</button>
|
||||||
|
<button onclick="regenerateCert('${cert}')" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">Regenerate</button>
|
||||||
|
</div>`;
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interfaces: {
|
||||||
|
api: '/api/interfaces',
|
||||||
|
searchId: 'search-interfaces',
|
||||||
|
dataKey: 'interfacesData',
|
||||||
|
filteredKey: 'filteredInterfaces',
|
||||||
|
containerId: 'interfacesTable',
|
||||||
|
paginationId: 'interfacesPagination',
|
||||||
|
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
|
||||||
|
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.ip.toLowerCase().includes(query),
|
||||||
|
renderItem: (item) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||||
|
tr.innerHTML = `<td class="p-3">${item.domain}</td>
|
||||||
|
<td class="p-3">${item.ip}</td>`;
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'local-dns': {
|
||||||
|
api: '/api/local-dns',
|
||||||
|
searchId: 'search-local-dns',
|
||||||
|
dataKey: 'localDnsData',
|
||||||
|
filteredKey: 'filteredLocalDns',
|
||||||
|
containerId: 'localDnsTable',
|
||||||
|
paginationId: 'localDnsPagination',
|
||||||
|
sort: (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
|
||||||
|
filter: (item, query) => {
|
||||||
|
const queryLower = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.name.toLowerCase().includes(queryLower) ||
|
||||||
|
item.type.toLowerCase().includes(queryLower) ||
|
||||||
|
Object.values(item).some(val => typeof val === 'string' && val.toLowerCase().includes(queryLower))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
renderItem: (item) => {
|
||||||
|
let valueStr = '';
|
||||||
|
if (item.type === 'MX') {
|
||||||
|
valueStr = `${item.preference || ''} ${item.exchange || ''}`.trim();
|
||||||
|
} else if (item.type === 'SRV') {
|
||||||
|
valueStr = `${item.priority || ''} ${item.weight || ''} ${item.port || ''} ${item.target || ''}`.trim();
|
||||||
|
} else if (item.type === 'SOA') {
|
||||||
|
valueStr = `${item.mname || ''} ${item.rname || ''} ${item.serial || ''} ${item.refresh || ''} ${item.retry || ''} ${item.expire || ''} ${item.minimum || ''}`.trim();
|
||||||
|
} else if (item.type === 'CAA') {
|
||||||
|
valueStr = `${item.flags || ''} ${item.tag || ''} ${item.value || ''}`.trim();
|
||||||
|
} else {
|
||||||
|
valueStr = item.data || item.value || '';
|
||||||
|
}
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="p-3">${item.name}</td>
|
||||||
|
<td class="p-3">${item.type}</td>
|
||||||
|
<td class="p-3 break-all">${valueStr}</td>
|
||||||
|
<td class="p-3">${item.ttl}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<button onclick="editLocalDns(${item.index})" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 mr-2">Edit</button>
|
||||||
|
<button onclick="deleteLocalDns(${item.index})" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||||
|
</td>`;
|
||||||
|
return tr;
|
||||||
|
},
|
||||||
|
postFetch: (data) => {
|
||||||
|
window.dnsConflictsData = data.conflicts || [];
|
||||||
|
window.filteredDnsConflicts = window.dnsConflictsData;
|
||||||
|
data.records = data.records.map((rec, index) => ({ ...rec, index }));
|
||||||
|
if (window.renderDnsConflicts) window.renderDnsConflicts();
|
||||||
|
return data.records;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'dns-conflicts': {
|
||||||
|
api: '/api/local-dns',
|
||||||
|
searchId: 'search-dns-conflicts',
|
||||||
|
dataKey: 'dnsConflictsData',
|
||||||
|
filteredKey: 'filteredDnsConflicts',
|
||||||
|
containerId: 'dnsConflictsTable',
|
||||||
|
paginationId: 'dnsConflictsPagination',
|
||||||
|
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
|
||||||
|
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.version.toLowerCase().includes(query) || item.publicIP.toLowerCase().includes(query),
|
||||||
|
renderItem: (item) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="p-3">${item.domain}</td>
|
||||||
|
<td class="p-3">${item.publicIP}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<label class="inline-flex items-center cursor-pointer">
|
||||||
|
<span class="mr-2">${item.version === 'public' ? 'Public' : 'P2P'}</span>
|
||||||
|
<input type="checkbox" ${item.version === 'public' ? 'checked' : ''} onchange="toggleVersionPreference('${item.domain}', this.checked)" class="sr-only peer">
|
||||||
|
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:bg-primary">
|
||||||
|
<div class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5"></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</td>`;
|
||||||
|
return tr;
|
||||||
|
},
|
||||||
|
postFetch: (data) => {
|
||||||
|
window.localDnsData = data.records.map((rec, index) => ({ ...rec, index }));
|
||||||
|
window.filteredLocalDns = window.localDnsData;
|
||||||
|
return data.conflicts || [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'host-servers': {
|
||||||
|
api: '/api/holesail-servers',
|
||||||
|
searchId: 'search-holesail',
|
||||||
|
dataKey: 'holesailServersData',
|
||||||
|
filteredKey: 'filteredHolesailServers',
|
||||||
|
containerId: 'holesailTable',
|
||||||
|
paginationId: 'holesailPagination',
|
||||||
|
sort: (a, b) => (a.opts.name || a.id).localeCompare(b.opts.name || b.id, undefined, { sensitivity: 'base' }),
|
||||||
|
filter: (item, query) => (item.opts.name || '').toLowerCase().includes(query) || item.id.toLowerCase().includes(query) || item.opts.port.toString().includes(query) || (item.info.url || '').toLowerCase().includes(query),
|
||||||
|
renderItem: (item) => {
|
||||||
|
const isPendingRestart = window.pendingServerRestarts && window.pendingServerRestarts.has(item.id);
|
||||||
|
const isPendingDelete = window.pendingServerDeletions && window.pendingServerDeletions.has(item.id);
|
||||||
|
const restartBtn = isPendingRestart
|
||||||
|
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
|
||||||
|
: `<button onclick="restartHolesailServer('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
|
||||||
|
const deleteBtn = isPendingDelete
|
||||||
|
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
|
||||||
|
: `<button onclick="deleteHolesailServer('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
|
||||||
|
const protocol = item.opts.udp ? 'UDP' : 'TCP';
|
||||||
|
const url = item.info.url || 'N/A';
|
||||||
|
const truncatedUrl = window.truncateUrl ? window.truncateUrl(url, 40) : url.length > 40 ? url.substring(0, 37) + '...' : url;
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.name || item.id}')" title="${item.opts.name || item.id}">${item.opts.name || item.id}</td>
|
||||||
|
<td class="p-3">${item.opts.port}</td>
|
||||||
|
<td class="p-3">${item.opts.host || '0.0.0.0'}</td>
|
||||||
|
<td class="p-3" title="${url}">${truncatedUrl}</td>
|
||||||
|
<td class="p-3">${protocol}</td>
|
||||||
|
<td class="p-3">${window.renderStatusBadge ? window.renderStatusBadge(item.info.state) : item.info.state}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
${restartBtn}
|
||||||
|
${deleteBtn}
|
||||||
|
</td>`;
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'host-clients': {
|
||||||
|
api: '/api/holesail-clients',
|
||||||
|
searchId: 'search-holesail-clients',
|
||||||
|
dataKey: 'holesailClientsData',
|
||||||
|
filteredKey: 'filteredHolesailClients',
|
||||||
|
containerId: 'holesailClientsTable',
|
||||||
|
paginationId: 'holesailClientsPagination',
|
||||||
|
sort: (a, b) => a.opts.domain.localeCompare(b.opts.domain, undefined, { sensitivity: 'base' }),
|
||||||
|
filter: (item, query) => item.opts.domain.toLowerCase().includes(query) || item.opts.key.toLowerCase().includes(query) || item.opts.port.toString().includes(query),
|
||||||
|
renderItem: (item) => {
|
||||||
|
const isPendingRestart = window.pendingClientRestarts && window.pendingClientRestarts.has(item.id);
|
||||||
|
const isPendingDelete = window.pendingClientDeletions && window.pendingClientDeletions.has(item.id);
|
||||||
|
const restartBtn = isPendingRestart
|
||||||
|
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
|
||||||
|
: `<button onclick="restartHolesailClient('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
|
||||||
|
const deleteBtn = isPendingDelete
|
||||||
|
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
|
||||||
|
: `<button onclick="deleteHolesailClient('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
|
||||||
|
const protocol = (item.opts.protocol || 'tcp').toUpperCase();
|
||||||
|
const key = item.opts.key || '';
|
||||||
|
const truncatedKey = window.truncateUrl ? window.truncateUrl(key, 30) : key.length > 30 ? key.substring(0, 27) + '...' : key;
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.domain}:${item.opts.port}')" title="${item.opts.domain}">${item.opts.domain}</td>
|
||||||
|
<td class="p-3" title="${key}">${truncatedKey}</td>
|
||||||
|
<td class="p-3">${item.opts.port}</td>
|
||||||
|
<td class="p-3">${protocol}</td>
|
||||||
|
<td class="p-3">${window.renderStatusBadge ? window.renderStatusBadge(item.info.state) : item.info.state}</td>
|
||||||
|
<td class="p-3">
|
||||||
|
${restartBtn}
|
||||||
|
${deleteBtn}
|
||||||
|
</td>`;
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
api: '/api/settings',
|
||||||
|
searchId: 'search-settings',
|
||||||
|
dataKey: 'settingsData',
|
||||||
|
filteredKey: 'filteredSettings',
|
||||||
|
containerId: 'settingsContainer',
|
||||||
|
paginationId: 'settingsPagination',
|
||||||
|
sort: null,
|
||||||
|
filter: null,
|
||||||
|
renderItem: null,
|
||||||
|
postFetch: (data) => {
|
||||||
|
// Store metadata globally for renderSettings to use
|
||||||
|
window.settingsMetadata = data.metadata || {};
|
||||||
|
// Return the full data object so metadata is preserved
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backups: {
|
||||||
|
api: '/api/backups',
|
||||||
|
searchId: 'search-backups',
|
||||||
|
dataKey: 'backupsData',
|
||||||
|
filteredKey: 'filteredBackups',
|
||||||
|
containerId: 'backupsTable',
|
||||||
|
paginationId: 'backupsPagination',
|
||||||
|
sort: (a, b) => new Date(b.timestamp) - new Date(a.timestamp),
|
||||||
|
filter: (item, query) => item.name.toLowerCase().includes(query) || item.timestamp.toLowerCase().includes(query),
|
||||||
|
renderItem: null // Custom renderer in backups.js
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// Highly customizable confirmation modal component
|
||||||
|
// Supports custom titles, messages, buttons, icons, types, and more
|
||||||
|
|
||||||
|
const ConfirmationModal = {
|
||||||
|
// Default configuration
|
||||||
|
defaults: {
|
||||||
|
title: 'Confirm Action',
|
||||||
|
message: 'Are you sure you want to proceed?',
|
||||||
|
type: 'default', // 'default', 'warning', 'danger', 'info', 'success'
|
||||||
|
confirmText: 'Confirm',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
confirmButtonClass: '',
|
||||||
|
cancelButtonClass: '',
|
||||||
|
showCancel: true,
|
||||||
|
allowHTML: false,
|
||||||
|
icon: null, // Custom icon HTML or null for default icons
|
||||||
|
onConfirm: null,
|
||||||
|
onCancel: null,
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
focusConfirm: true,
|
||||||
|
width: 'max-w-md', // Tailwind width class
|
||||||
|
zIndex: 'z-50'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Type-specific configurations
|
||||||
|
typeConfigs: {
|
||||||
|
warning: {
|
||||||
|
title: 'Warning',
|
||||||
|
icon: `<svg class="w-6 h-6 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>`,
|
||||||
|
confirmButtonClass: 'bg-yellow-glass'
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
title: 'Danger',
|
||||||
|
icon: `<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>`,
|
||||||
|
confirmButtonClass: 'bg-red-glass'
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
title: 'Information',
|
||||||
|
icon: `<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>`,
|
||||||
|
confirmButtonClass: 'bg-blue-glass'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
title: 'Success',
|
||||||
|
icon: `<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>`,
|
||||||
|
confirmButtonClass: 'bg-green-glass'
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
title: 'Confirm Action',
|
||||||
|
icon: `<svg class="w-6 h-6 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create and show the modal
|
||||||
|
show: function(options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Merge options with defaults and type config
|
||||||
|
const config = { ...this.defaults, ...options };
|
||||||
|
const typeConfig = this.typeConfigs[config.type] || this.typeConfigs.default;
|
||||||
|
|
||||||
|
// Apply type-specific config
|
||||||
|
if (config.type !== 'default' && !options.title) {
|
||||||
|
config.title = typeConfig.title;
|
||||||
|
}
|
||||||
|
if (!config.icon && typeConfig.icon) {
|
||||||
|
config.icon = typeConfig.icon;
|
||||||
|
}
|
||||||
|
if (config.type !== 'default' && !options.confirmButtonClass) {
|
||||||
|
config.confirmButtonClass = typeConfig.confirmButtonClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create modal element
|
||||||
|
let modal = document.getElementById('confirmationModal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = this._createModalElement();
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modal content
|
||||||
|
this._updateModalContent(modal, config);
|
||||||
|
|
||||||
|
// Set up event handlers
|
||||||
|
const confirmBtn = modal.querySelector('[data-confirm-btn]');
|
||||||
|
const cancelBtn = modal.querySelector('[data-cancel-btn]');
|
||||||
|
const backdrop = modal.querySelector('.modal-backdrop');
|
||||||
|
|
||||||
|
// Clean up previous handlers
|
||||||
|
const newConfirmHandler = () => {
|
||||||
|
modal.close();
|
||||||
|
if (config.onConfirm) {
|
||||||
|
try {
|
||||||
|
const result = config.onConfirm();
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
result.then(resolve).catch(reject);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
this._cleanup(modal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const newCancelHandler = () => {
|
||||||
|
modal.close();
|
||||||
|
if (config.onCancel) {
|
||||||
|
try {
|
||||||
|
const result = config.onCancel();
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
result.then(() => resolve(false)).catch(reject);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
this._cleanup(modal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHandler = (e) => {
|
||||||
|
if (config.closeOnEscape && e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
newCancelHandler();
|
||||||
|
} else if (e.key === 'Enter' && config.focusConfirm) {
|
||||||
|
e.preventDefault();
|
||||||
|
newConfirmHandler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach handlers
|
||||||
|
confirmBtn.addEventListener('click', newConfirmHandler);
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', newCancelHandler);
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', escapeHandler);
|
||||||
|
|
||||||
|
// Store handlers for cleanup
|
||||||
|
modal._handlers = {
|
||||||
|
confirm: newConfirmHandler,
|
||||||
|
cancel: newCancelHandler,
|
||||||
|
escape: escapeHandler
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backdrop click handler
|
||||||
|
if (config.closeOnBackdrop) {
|
||||||
|
const backdropHandler = (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
newCancelHandler();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
modal.addEventListener('click', backdropHandler);
|
||||||
|
modal._handlers.backdrop = backdropHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.showModal();
|
||||||
|
|
||||||
|
// Focus confirm button if specified
|
||||||
|
if (config.focusConfirm) {
|
||||||
|
setTimeout(() => confirmBtn.focus(), 100);
|
||||||
|
} else if (cancelBtn) {
|
||||||
|
setTimeout(() => cancelBtn.focus(), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create the modal DOM element
|
||||||
|
_createModalElement: function() {
|
||||||
|
const modal = document.createElement('dialog');
|
||||||
|
modal.id = 'confirmationModal';
|
||||||
|
modal.className = 'confirmation-modal p-0 bg-transparent border-0 outline-none rounded-lg shadow-2xl w-full max-w-md';
|
||||||
|
modal.setAttribute('style', 'border: none; outline: none; padding: 0; margin: 0; background: transparent;');
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content theme-glass rounded-lg shadow-xl border-0 outline-none ${this.defaults.width} mx-auto" style="background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(40px) saturate(200%); -webkit-backdrop-filter: blur(40px) saturate(200%); border: 1px solid var(--border-color-strong); box-shadow: var(--shadow-xl), inset 0 1px 0 rgba(255, 255, 255, 0.15); position: relative; overflow: hidden;">
|
||||||
|
<div style="position: absolute; top: 0; left: 0; right: 0; height: 40%; background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0) 100%); pointer-events: none; border-radius: inherit; z-index: 0;"></div>
|
||||||
|
<div style="position: relative; z-index: 1;">
|
||||||
|
<div class="modal-header p-6 pb-4" style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="modal-icon flex-shrink-0"></div>
|
||||||
|
<h3 class="modal-title text-xl font-bold flex-1" style="color: var(--text-primary);"></h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-6">
|
||||||
|
<div class="modal-message" style="color: var(--text-secondary);"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer p-6 pt-4 flex justify-end gap-3" style="border-top: 1px solid var(--border-color);">
|
||||||
|
<button data-cancel-btn class="px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none"></button>
|
||||||
|
<button data-confirm-btn class="px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return modal;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update modal content based on config
|
||||||
|
_updateModalContent: function(modal, config) {
|
||||||
|
const titleEl = modal.querySelector('.modal-title');
|
||||||
|
const messageEl = modal.querySelector('.modal-message');
|
||||||
|
const iconEl = modal.querySelector('.modal-icon');
|
||||||
|
const confirmBtn = modal.querySelector('[data-confirm-btn]');
|
||||||
|
const cancelBtn = modal.querySelector('[data-cancel-btn]');
|
||||||
|
const footer = modal.querySelector('.modal-footer');
|
||||||
|
const content = modal.querySelector('.modal-content');
|
||||||
|
|
||||||
|
// Update title
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = config.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update message
|
||||||
|
if (messageEl) {
|
||||||
|
if (config.allowHTML) {
|
||||||
|
messageEl.innerHTML = config.message;
|
||||||
|
} else {
|
||||||
|
messageEl.textContent = config.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update icon
|
||||||
|
if (iconEl) {
|
||||||
|
if (config.icon) {
|
||||||
|
iconEl.innerHTML = config.icon;
|
||||||
|
iconEl.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
iconEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update buttons with glass styling
|
||||||
|
if (confirmBtn) {
|
||||||
|
confirmBtn.textContent = config.confirmText;
|
||||||
|
confirmBtn.className = 'px-4 py-2 rounded-lg font-medium transition-all focus:outline-none btn-glass-primary';
|
||||||
|
// Apply type-specific colors
|
||||||
|
let bgColor, borderColor;
|
||||||
|
if (config.type === 'warning') {
|
||||||
|
bgColor = 'rgba(245, 158, 11, 0.3)';
|
||||||
|
borderColor = 'rgba(245, 158, 11, 0.5)';
|
||||||
|
} else if (config.type === 'danger') {
|
||||||
|
bgColor = 'rgba(239, 68, 68, 0.3)';
|
||||||
|
borderColor = 'rgba(239, 68, 68, 0.5)';
|
||||||
|
} else if (config.type === 'info') {
|
||||||
|
bgColor = 'rgba(59, 130, 246, 0.3)';
|
||||||
|
borderColor = 'rgba(59, 130, 246, 0.5)';
|
||||||
|
} else if (config.type === 'success') {
|
||||||
|
bgColor = 'rgba(16, 185, 129, 0.3)';
|
||||||
|
borderColor = 'rgba(16, 185, 129, 0.5)';
|
||||||
|
} else {
|
||||||
|
bgColor = 'rgba(99, 102, 241, 0.3)';
|
||||||
|
borderColor = 'rgba(99, 102, 241, 0.5)';
|
||||||
|
}
|
||||||
|
confirmBtn.style.cssText = `
|
||||||
|
background: ${bgColor};
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid ${borderColor};
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 4px 6px -1px ${borderColor.replace('0.5', '0.2')}, 0 2px 4px -1px ${borderColor.replace('0.5', '0.1')}, inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
confirmBtn.addEventListener('mouseenter', function() {
|
||||||
|
this.style.background = bgColor.replace('0.3', '0.5');
|
||||||
|
this.style.borderColor = borderColor.replace('0.5', '0.7');
|
||||||
|
this.style.transform = 'translateY(-2px)';
|
||||||
|
});
|
||||||
|
confirmBtn.addEventListener('mouseleave', function() {
|
||||||
|
this.style.background = bgColor;
|
||||||
|
this.style.borderColor = borderColor;
|
||||||
|
this.style.transform = 'translateY(0)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
if (config.showCancel) {
|
||||||
|
cancelBtn.textContent = config.cancelText;
|
||||||
|
cancelBtn.className = 'px-4 py-2 rounded-lg font-medium transition-all focus:outline-none btn-glass';
|
||||||
|
cancelBtn.style.cssText = `
|
||||||
|
background: var(--bg-glass);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
cancelBtn.addEventListener('mouseenter', function() {
|
||||||
|
this.style.background = 'var(--bg-glass-hover)';
|
||||||
|
this.style.borderColor = 'var(--border-color-strong)';
|
||||||
|
this.style.boxShadow = 'var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08)';
|
||||||
|
});
|
||||||
|
cancelBtn.addEventListener('mouseleave', function() {
|
||||||
|
this.style.background = 'var(--bg-glass)';
|
||||||
|
this.style.borderColor = 'var(--border-color)';
|
||||||
|
this.style.boxShadow = 'var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03)';
|
||||||
|
});
|
||||||
|
cancelBtn.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
cancelBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update width
|
||||||
|
if (content && config.width) {
|
||||||
|
content.className = content.className.replace(/max-w-\w+/, '');
|
||||||
|
content.classList.add(config.width);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clean up event handlers
|
||||||
|
_cleanup: function(modal) {
|
||||||
|
if (!modal._handlers) return;
|
||||||
|
|
||||||
|
const confirmBtn = modal.querySelector('[data-confirm-btn]');
|
||||||
|
const cancelBtn = modal.querySelector('[data-cancel-btn]');
|
||||||
|
|
||||||
|
if (confirmBtn && modal._handlers.confirm) {
|
||||||
|
confirmBtn.removeEventListener('click', modal._handlers.confirm);
|
||||||
|
}
|
||||||
|
if (cancelBtn && modal._handlers.cancel) {
|
||||||
|
cancelBtn.removeEventListener('click', modal._handlers.cancel);
|
||||||
|
}
|
||||||
|
if (modal._handlers.escape) {
|
||||||
|
document.removeEventListener('keydown', modal._handlers.escape);
|
||||||
|
}
|
||||||
|
if (modal._handlers.backdrop) {
|
||||||
|
modal.removeEventListener('click', modal._handlers.backdrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete modal._handlers;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Convenience methods for common types
|
||||||
|
warning: function(message, options = {}) {
|
||||||
|
return this.show({ ...options, message, type: 'warning' });
|
||||||
|
},
|
||||||
|
|
||||||
|
danger: function(message, options = {}) {
|
||||||
|
return this.show({ ...options, message, type: 'danger' });
|
||||||
|
},
|
||||||
|
|
||||||
|
info: function(message, options = {}) {
|
||||||
|
return this.show({ ...options, message, type: 'info' });
|
||||||
|
},
|
||||||
|
|
||||||
|
success: function(message, options = {}) {
|
||||||
|
return this.show({ ...options, message, type: 'success' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Simple confirm replacement (backward compatible)
|
||||||
|
confirm: function(message, options = {}) {
|
||||||
|
return this.show({ ...options, message });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Alert-style modal (single button, no cancel)
|
||||||
|
alert: function(message, options = {}) {
|
||||||
|
return this.show({
|
||||||
|
...options,
|
||||||
|
message,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: options.confirmText || 'OK',
|
||||||
|
type: options.type || 'info',
|
||||||
|
focusConfirm: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make it globally available
|
||||||
|
window.ConfirmationModal = ConfirmationModal;
|
||||||
|
|
||||||
|
// Also provide a simple showConfirm function for backward compatibility
|
||||||
|
window.showConfirm = function(message, callback, options = {}) {
|
||||||
|
return ConfirmationModal.show({
|
||||||
|
...options,
|
||||||
|
message,
|
||||||
|
onConfirm: callback
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// Global infinite scroll state for all tabs
|
||||||
|
window.infiniteScrollState = {};
|
||||||
|
|
||||||
|
// Core UI functions - generic fetch, filter, pagination
|
||||||
|
async function genericFetch(tabId, shouldRender = true) {
|
||||||
|
const config = window.tabs[tabId];
|
||||||
|
if (!config) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(config.api);
|
||||||
|
let data = await res.json();
|
||||||
|
if (config.postFetch) {
|
||||||
|
const result = config.postFetch(data);
|
||||||
|
// Handle both sync and async postFetch functions
|
||||||
|
data = result instanceof Promise ? await result : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for settings tab
|
||||||
|
if (tabId === 'settings') {
|
||||||
|
window[config.dataKey] = data;
|
||||||
|
if (shouldRender && window.renderSettings) await window.renderSettings(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.sort && Array.isArray(data)) data.sort(config.sort);
|
||||||
|
window[config.dataKey] = data;
|
||||||
|
|
||||||
|
// Reset infinite scroll state when fetching fresh data
|
||||||
|
if (window.infiniteScrollState && window.infiniteScrollState[tabId]) {
|
||||||
|
window.infiniteScrollState[tabId].loadedCount = 0;
|
||||||
|
window.infiniteScrollState[tabId].lastQuery = '';
|
||||||
|
// Disconnect existing observer
|
||||||
|
if (window.infiniteScrollState[tabId].observer) {
|
||||||
|
window.infiniteScrollState[tabId].observer.disconnect();
|
||||||
|
window.infiniteScrollState[tabId].observer = null;
|
||||||
|
}
|
||||||
|
// Clear container
|
||||||
|
const container = document.getElementById(config.containerId);
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRender) {
|
||||||
|
genericFilter(tabId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch ${tabId}:`, err);
|
||||||
|
if (window.showNotification) window.showNotification(`Failed to load ${tabId}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericFilter(tabId) {
|
||||||
|
const config = window.tabs[tabId];
|
||||||
|
if (!config || !config.filter) return;
|
||||||
|
const searchEl = document.getElementById(config.searchId);
|
||||||
|
if (!searchEl) return;
|
||||||
|
const query = searchEl.value.toLowerCase();
|
||||||
|
const data = window[config.dataKey];
|
||||||
|
if (!data || !Array.isArray(data)) return;
|
||||||
|
const filtered = data.filter(item => config.filter(item, query));
|
||||||
|
window[config.filteredKey] = filtered;
|
||||||
|
|
||||||
|
// Use infinite scroll for all tabs
|
||||||
|
genericRenderInfiniteScroll(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global infinite scroll renderer
|
||||||
|
function genericRenderInfiniteScroll(tabId) {
|
||||||
|
const config = window.tabs[tabId];
|
||||||
|
if (!config) {
|
||||||
|
console.warn(`No config found for tab: ${tabId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = window[config.filteredKey] || window[config.dataKey];
|
||||||
|
if (!data) {
|
||||||
|
console.warn(`No data found for tab: ${tabId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (config.preRender) config.preRender(data.length);
|
||||||
|
|
||||||
|
// Update count if countId is specified
|
||||||
|
if (config.countId) {
|
||||||
|
const countEl = document.getElementById(config.countId);
|
||||||
|
if (countEl) {
|
||||||
|
const totalCount = data.length;
|
||||||
|
const searchEl = document.getElementById(config.searchId);
|
||||||
|
const searchQuery = searchEl ? searchEl.value : '';
|
||||||
|
if (searchQuery) {
|
||||||
|
countEl.textContent = `${totalCount.toLocaleString()} (filtered)`;
|
||||||
|
} else {
|
||||||
|
countEl.textContent = totalCount.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById(config.containerId);
|
||||||
|
if (!container) {
|
||||||
|
console.warn(`Container not found: ${config.containerId} for tab: ${tabId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize infinite scroll state for this tab
|
||||||
|
if (!window.infiniteScrollState[tabId]) {
|
||||||
|
window.infiniteScrollState[tabId] = {
|
||||||
|
loadedCount: 0,
|
||||||
|
observer: null,
|
||||||
|
batchSize: calculateBatchSize(tabId),
|
||||||
|
lastQuery: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = window.infiniteScrollState[tabId];
|
||||||
|
const searchEl = document.getElementById(config.searchId);
|
||||||
|
const query = searchEl ? searchEl.value.toLowerCase() : '';
|
||||||
|
const isNewSearch = state.lastQuery !== query;
|
||||||
|
|
||||||
|
// Reset if new search
|
||||||
|
if (isNewSearch) {
|
||||||
|
state.loadedCount = 0;
|
||||||
|
state.lastQuery = query;
|
||||||
|
state.batchSize = calculateBatchSize(tabId);
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Disconnect existing observer
|
||||||
|
if (state.observer) {
|
||||||
|
state.observer.disconnect();
|
||||||
|
state.observer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty state
|
||||||
|
if (data.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
const isTable = container.tagName === 'TBODY';
|
||||||
|
if (isTable) {
|
||||||
|
const emptyRow = document.createElement('tr');
|
||||||
|
emptyRow.className = 'border-b';
|
||||||
|
const colCount = tabId === 'host-servers' ? 7 : (tabId === 'host-clients' ? 6 : (tabId === 'domains' ? 4 : 3));
|
||||||
|
emptyRow.innerHTML = `<td colspan="${colCount}" class="p-8 text-center theme-text-tertiary">
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<span class="text-4xl">📭</span>
|
||||||
|
<span class="text-lg font-semibold">No ${tabId === 'host-servers' ? 'servers' : tabId === 'host-clients' ? 'clients' : 'items'} found</span>
|
||||||
|
<span class="text-sm">${tabId === 'host-servers' ? 'Create a server to get started' : tabId === 'host-clients' ? 'Create a client to get started' : 'Try adjusting your search'}</span>
|
||||||
|
</div>
|
||||||
|
</td>`;
|
||||||
|
container.appendChild(emptyRow);
|
||||||
|
} else {
|
||||||
|
// For list containers (ul/ol)
|
||||||
|
const emptyItem = document.createElement('li');
|
||||||
|
emptyItem.className = 'p-8 text-center theme-text-tertiary';
|
||||||
|
emptyItem.innerHTML = `
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<span class="text-4xl">📭</span>
|
||||||
|
<span class="text-lg font-semibold">No ${tabId === 'peers' ? 'peers' : 'items'} found</span>
|
||||||
|
<span class="text-sm">Try adjusting your search</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(emptyItem);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial batch or next batch
|
||||||
|
loadNextBatch(tabId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate batch size based on tab type and viewport
|
||||||
|
function calculateBatchSize(tabId) {
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
// Estimate row/item height (approximately 50-80px per item including padding)
|
||||||
|
const estimatedItemHeight = tabId === 'peers' ? 100 : (tabId === 'certs' ? 70 : 60);
|
||||||
|
const visibleItems = Math.floor((viewportHeight - 300) / estimatedItemHeight);
|
||||||
|
// Load 2-3x visible items per batch
|
||||||
|
return Math.max(10, Math.min(50, visibleItems * 2.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load next batch of items
|
||||||
|
function loadNextBatch(tabId, data) {
|
||||||
|
const config = window.tabs[tabId];
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const state = window.infiniteScrollState[tabId];
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
const container = document.getElementById(config.containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const start = state.loadedCount;
|
||||||
|
const end = Math.min(start + state.batchSize, data.length);
|
||||||
|
const batch = data.slice(start, end);
|
||||||
|
|
||||||
|
if (batch.length === 0) {
|
||||||
|
// No more data to load
|
||||||
|
if (state.observer) {
|
||||||
|
state.observer.disconnect();
|
||||||
|
state.observer = null;
|
||||||
|
}
|
||||||
|
// Remove sentinel if exists (but keep config sentinel)
|
||||||
|
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||||
|
if (sentinel && !config.sentinelId) sentinel.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render batch
|
||||||
|
batch.forEach(item => {
|
||||||
|
const element = config.renderItem(item);
|
||||||
|
container.appendChild(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.loadedCount = end;
|
||||||
|
|
||||||
|
// Setup IntersectionObserver for next batch
|
||||||
|
if (end < data.length) {
|
||||||
|
setupInfiniteScrollObserver(tabId, container);
|
||||||
|
} else {
|
||||||
|
// All data loaded, disconnect observer
|
||||||
|
if (state.observer) {
|
||||||
|
state.observer.disconnect();
|
||||||
|
state.observer = null;
|
||||||
|
}
|
||||||
|
// Remove sentinel if exists (but keep config sentinel)
|
||||||
|
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||||
|
if (sentinel && !config.sentinelId) sentinel.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup IntersectionObserver to detect when to load more
|
||||||
|
function setupInfiniteScrollObserver(tabId, container) {
|
||||||
|
const config = window.tabs[tabId];
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const state = window.infiniteScrollState[tabId];
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
// Create or get sentinel element
|
||||||
|
// Check for existing sentinel ID in config first
|
||||||
|
let sentinel = config.sentinelId ? document.getElementById(config.sentinelId) : null;
|
||||||
|
if (!sentinel) {
|
||||||
|
// Check for existing sentinel by class
|
||||||
|
sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||||
|
}
|
||||||
|
if (!sentinel) {
|
||||||
|
// Create new sentinel
|
||||||
|
const isTable = container.tagName === 'TBODY';
|
||||||
|
if (isTable) {
|
||||||
|
sentinel = document.createElement('tr');
|
||||||
|
sentinel.className = 'infinite-scroll-sentinel';
|
||||||
|
sentinel.innerHTML = `<td colspan="${container.querySelector('tr')?.cells.length || 2}" style="height: 1px; padding: 0;"></td>`;
|
||||||
|
} else {
|
||||||
|
sentinel = document.createElement('div');
|
||||||
|
sentinel.className = 'infinite-scroll-sentinel';
|
||||||
|
sentinel.style.height = '1px';
|
||||||
|
sentinel.style.width = '100%';
|
||||||
|
sentinel.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
container.appendChild(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the scrollable container (must be a parent with overflow-y-auto or overflow-auto)
|
||||||
|
const scrollContainer = container.closest('.overflow-y-auto, .overflow-auto');
|
||||||
|
|
||||||
|
if (!scrollContainer) {
|
||||||
|
console.warn(`No scrollable container found for tab: ${tabId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect existing observer
|
||||||
|
if (state.observer) {
|
||||||
|
state.observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const data = window[config.filteredKey] || window[config.dataKey];
|
||||||
|
if (data) {
|
||||||
|
loadNextBatch(tabId, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
root: scrollContainer,
|
||||||
|
rootMargin: '200px' // Start loading 200px before reaching the sentinel
|
||||||
|
});
|
||||||
|
|
||||||
|
state.observer.observe(sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy entries lazy scroll - now uses generic infinite scroll
|
||||||
|
function renderEntriesLazy() {
|
||||||
|
const config = window.tabs.entries;
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const data = window[config.filteredKey] || window[config.dataKey];
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
// Update count display
|
||||||
|
const countEl = document.getElementById(config.countId);
|
||||||
|
if (countEl) {
|
||||||
|
const totalCount = data.length;
|
||||||
|
const searchEl = document.getElementById(config.searchId);
|
||||||
|
const searchQuery = searchEl ? searchEl.value : '';
|
||||||
|
if (searchQuery) {
|
||||||
|
countEl.textContent = `${totalCount.toLocaleString()} (filtered)`;
|
||||||
|
} else {
|
||||||
|
countEl.textContent = totalCount.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use generic infinite scroll
|
||||||
|
genericRenderInfiniteScroll('entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter functions for each tab
|
||||||
|
function filterDomains() { genericFilter('domains'); }
|
||||||
|
function filterEntries() { genericFilter('entries'); }
|
||||||
|
function filterPeers() { genericFilter('peers'); }
|
||||||
|
function filterCerts() { genericFilter('certs'); }
|
||||||
|
function filterInterfaces() { genericFilter('interfaces'); }
|
||||||
|
function filterLocalDNS() { genericFilter('local-dns'); }
|
||||||
|
function filterDnsConflicts() { genericFilter('dns-conflicts'); }
|
||||||
|
function filterHolesailServers() { genericFilter('host-servers'); }
|
||||||
|
function filterHolesailClients() { genericFilter('host-clients'); }
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.genericFetch = genericFetch;
|
||||||
|
window.genericFilter = genericFilter;
|
||||||
|
window.genericRenderInfiniteScroll = genericRenderInfiniteScroll;
|
||||||
|
window.renderEntriesLazy = renderEntriesLazy;
|
||||||
|
window.filterDomains = filterDomains;
|
||||||
|
window.filterEntries = filterEntries;
|
||||||
|
window.filterPeers = filterPeers;
|
||||||
|
window.filterCerts = filterCerts;
|
||||||
|
window.filterInterfaces = filterInterfaces;
|
||||||
|
window.filterLocalDNS = filterLocalDNS;
|
||||||
|
window.filterDnsConflicts = filterDnsConflicts;
|
||||||
|
window.filterHolesailServers = filterHolesailServers;
|
||||||
|
window.filterHolesailClients = filterHolesailClients;
|
||||||
|
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
// Diagnostics UI functions
|
||||||
|
|
||||||
|
let diagnosticsResults = [];
|
||||||
|
let activeStreams = new Map(); // Track active streaming requests for cancellation
|
||||||
|
let handlersSetup = false; // Track if Enter key handlers have been set up
|
||||||
|
|
||||||
|
// Run DNS lookup
|
||||||
|
async function runDnsLookup() {
|
||||||
|
const domainEl = document.getElementById('dns-lookup-domain');
|
||||||
|
const typeEl = document.getElementById('dns-lookup-type');
|
||||||
|
const buttonEl = document.querySelector('button[onclick="runDnsLookup()"]');
|
||||||
|
if (!domainEl || !typeEl) {
|
||||||
|
console.error('DNS lookup elements not found');
|
||||||
|
if (window.showNotification) window.showNotification('DNS lookup form not found', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = domainEl.value.trim();
|
||||||
|
const type = typeEl.value;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
if (window.showNotification) window.showNotification('Please enter a domain', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
if (buttonEl) {
|
||||||
|
buttonEl.disabled = true;
|
||||||
|
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Looking up...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/diagnostics/dns-lookup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain, type })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
displayDiagnosticResult('DNS Lookup', result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DNS lookup failed:', err);
|
||||||
|
if (window.showNotification) window.showNotification('DNS lookup failed: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
// Restore button
|
||||||
|
if (buttonEl) {
|
||||||
|
buttonEl.disabled = false;
|
||||||
|
buttonEl.innerHTML = 'Lookup';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run ping
|
||||||
|
async function runPing() {
|
||||||
|
const targetEl = document.getElementById('ping-target');
|
||||||
|
const countEl = document.getElementById('ping-count');
|
||||||
|
const buttonEl = document.querySelector('button[onclick="runPing()"]');
|
||||||
|
if (!targetEl) {
|
||||||
|
console.error('Ping elements not found');
|
||||||
|
if (window.showNotification) window.showNotification('Ping form not found', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = targetEl.value.trim();
|
||||||
|
const count = parseInt(countEl?.value || '4', 10);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
if (window.showNotification) window.showNotification('Please enter a target', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
if (buttonEl) {
|
||||||
|
buttonEl.disabled = true;
|
||||||
|
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Pinging...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create result container for streaming
|
||||||
|
const resultId = `ping-${Date.now()}`;
|
||||||
|
const resultDiv = createStreamingResultContainer('Ping', resultId, () => cancelStream(resultId));
|
||||||
|
const outputContainer = resultDiv.querySelector('.streaming-output');
|
||||||
|
|
||||||
|
let reader = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/diagnostics/ping', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target, count, stream: true })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store stream for cancellation
|
||||||
|
reader = response.body.getReader();
|
||||||
|
activeStreams.set(resultId, { reader, cancelled: false });
|
||||||
|
|
||||||
|
// Stream the response
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let fullOutput = '';
|
||||||
|
let fullError = '';
|
||||||
|
let finalResult = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const streamInfo = activeStreams.get(resultId);
|
||||||
|
if (streamInfo && streamInfo.cancelled) {
|
||||||
|
cancelled = true;
|
||||||
|
if (outputContainer) {
|
||||||
|
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||||
|
}
|
||||||
|
if (resultDiv) {
|
||||||
|
updateStreamingResultStatus(resultDiv, false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let readResult;
|
||||||
|
try {
|
||||||
|
readResult = await reader.read();
|
||||||
|
} catch (err) {
|
||||||
|
// Stream was cancelled or error occurred
|
||||||
|
if (err.name === 'AbortError' || activeStreams.get(resultId)?.cancelled) {
|
||||||
|
cancelled = true;
|
||||||
|
if (outputContainer) {
|
||||||
|
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||||
|
}
|
||||||
|
if (resultDiv) {
|
||||||
|
updateStreamingResultStatus(resultDiv, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { done, value } = readResult;
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(line);
|
||||||
|
|
||||||
|
if (chunk.type === 'output') {
|
||||||
|
fullOutput += chunk.data + '\n';
|
||||||
|
appendStreamingOutput(outputContainer, chunk.data, 'output');
|
||||||
|
} else if (chunk.type === 'error') {
|
||||||
|
fullError += chunk.data;
|
||||||
|
appendStreamingOutput(outputContainer, chunk.data, 'error');
|
||||||
|
} else if (chunk.type === 'complete') {
|
||||||
|
finalResult = chunk;
|
||||||
|
updateStreamingResultStatus(resultDiv, chunk.success);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
activeStreams.delete(resultId);
|
||||||
|
|
||||||
|
// Display final result if not cancelled
|
||||||
|
if (!cancelled && finalResult) {
|
||||||
|
displayDiagnosticResult('Ping', {
|
||||||
|
success: finalResult.success,
|
||||||
|
target,
|
||||||
|
count,
|
||||||
|
output: fullOutput,
|
||||||
|
error: fullError || null,
|
||||||
|
responseTime: finalResult.responseTime
|
||||||
|
});
|
||||||
|
// Remove streaming container
|
||||||
|
resultDiv.remove();
|
||||||
|
} else if (cancelled) {
|
||||||
|
// Keep the streaming container showing cancelled state
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ping failed:', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
if (window.showNotification) window.showNotification('Ping failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
if (resultDiv && resultDiv.parentNode && !cancelled) {
|
||||||
|
resultDiv.remove();
|
||||||
|
}
|
||||||
|
activeStreams.delete(resultId);
|
||||||
|
} finally {
|
||||||
|
// Restore button
|
||||||
|
if (buttonEl) {
|
||||||
|
buttonEl.disabled = false;
|
||||||
|
buttonEl.innerHTML = 'Ping';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run traceroute
|
||||||
|
async function runTraceroute() {
|
||||||
|
const targetEl = document.getElementById('traceroute-target');
|
||||||
|
const buttonEl = document.querySelector('button[onclick="runTraceroute()"]');
|
||||||
|
if (!targetEl) {
|
||||||
|
console.error('Traceroute elements not found');
|
||||||
|
if (window.showNotification) window.showNotification('Traceroute form not found', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = targetEl.value.trim();
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
if (window.showNotification) window.showNotification('Please enter a target', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
if (buttonEl) {
|
||||||
|
buttonEl.disabled = true;
|
||||||
|
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Tracing...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create result container for streaming
|
||||||
|
const resultId = `traceroute-${Date.now()}`;
|
||||||
|
const resultDiv = createStreamingResultContainer('Traceroute', resultId, () => cancelStream(resultId));
|
||||||
|
const outputContainer = resultDiv.querySelector('.streaming-output');
|
||||||
|
|
||||||
|
let reader = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/diagnostics/traceroute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target, stream: true })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store stream for cancellation
|
||||||
|
reader = response.body.getReader();
|
||||||
|
activeStreams.set(resultId, { reader, cancelled: false });
|
||||||
|
|
||||||
|
// Stream the response
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let fullOutput = '';
|
||||||
|
let fullError = '';
|
||||||
|
let finalResult = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const streamInfo = activeStreams.get(resultId);
|
||||||
|
if (streamInfo && streamInfo.cancelled) {
|
||||||
|
cancelled = true;
|
||||||
|
if (outputContainer) {
|
||||||
|
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||||
|
}
|
||||||
|
if (resultDiv) {
|
||||||
|
updateStreamingResultStatus(resultDiv, false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let readResult;
|
||||||
|
try {
|
||||||
|
readResult = await reader.read();
|
||||||
|
} catch (err) {
|
||||||
|
// Stream was cancelled or error occurred
|
||||||
|
if (err.name === 'AbortError' || activeStreams.get(resultId)?.cancelled) {
|
||||||
|
cancelled = true;
|
||||||
|
if (outputContainer) {
|
||||||
|
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||||
|
}
|
||||||
|
if (resultDiv) {
|
||||||
|
updateStreamingResultStatus(resultDiv, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { done, value } = readResult;
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(line);
|
||||||
|
|
||||||
|
if (chunk.type === 'output') {
|
||||||
|
fullOutput += chunk.data + '\n';
|
||||||
|
appendStreamingOutput(outputContainer, chunk.data, 'output');
|
||||||
|
} else if (chunk.type === 'error') {
|
||||||
|
fullError += chunk.data;
|
||||||
|
appendStreamingOutput(outputContainer, chunk.data, 'error');
|
||||||
|
} else if (chunk.type === 'complete') {
|
||||||
|
finalResult = chunk;
|
||||||
|
updateStreamingResultStatus(resultDiv, chunk.success);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
activeStreams.delete(resultId);
|
||||||
|
|
||||||
|
// Display final result if not cancelled
|
||||||
|
if (!cancelled && finalResult) {
|
||||||
|
displayDiagnosticResult('Traceroute', {
|
||||||
|
success: finalResult.success,
|
||||||
|
target,
|
||||||
|
output: fullOutput,
|
||||||
|
error: fullError || null,
|
||||||
|
responseTime: finalResult.responseTime
|
||||||
|
});
|
||||||
|
// Remove streaming container
|
||||||
|
resultDiv.remove();
|
||||||
|
} else if (cancelled) {
|
||||||
|
// Keep the streaming container showing cancelled state
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Traceroute failed:', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
if (window.showNotification) window.showNotification('Traceroute failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
if (resultDiv && resultDiv.parentNode && !cancelled) {
|
||||||
|
resultDiv.remove();
|
||||||
|
}
|
||||||
|
activeStreams.delete(resultId);
|
||||||
|
} finally {
|
||||||
|
// Restore button
|
||||||
|
if (buttonEl) {
|
||||||
|
buttonEl.disabled = false;
|
||||||
|
buttonEl.innerHTML = 'Traceroute';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
async function testConnection() {
|
||||||
|
const domainEl = document.getElementById('connection-domain');
|
||||||
|
const portEl = document.getElementById('connection-port');
|
||||||
|
const buttonEl = document.querySelector('button[onclick="testConnection()"]');
|
||||||
|
if (!domainEl || !portEl) {
|
||||||
|
console.error('Connection test elements not found');
|
||||||
|
if (window.showNotification) window.showNotification('Connection test form not found', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = domainEl.value.trim();
|
||||||
|
const port = parseInt(portEl.value, 10);
|
||||||
|
|
||||||
|
if (!domain || !port || isNaN(port)) {
|
||||||
|
if (window.showNotification) window.showNotification('Please enter a valid domain and port', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
if (buttonEl) {
|
||||||
|
buttonEl.disabled = true;
|
||||||
|
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Testing...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/diagnostics/connection-test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain, port })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
displayDiagnosticResult('Connection Test', result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Connection test failed:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Connection test failed: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
// Restore button
|
||||||
|
if (buttonEl) {
|
||||||
|
buttonEl.disabled = false;
|
||||||
|
buttonEl.innerHTML = 'Test Connection';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch bandwidth stats
|
||||||
|
async function fetchBandwidth() {
|
||||||
|
const buttonEl = document.querySelector('button[onclick="fetchBandwidth()"]');
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
if (buttonEl) {
|
||||||
|
// Store original HTML, not just text, since button might have HTML content
|
||||||
|
if (!buttonEl.dataset.originalHtml) {
|
||||||
|
buttonEl.dataset.originalHtml = buttonEl.innerHTML;
|
||||||
|
}
|
||||||
|
const originalHtml = buttonEl.dataset.originalHtml;
|
||||||
|
|
||||||
|
buttonEl.disabled = true;
|
||||||
|
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Loading...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/diagnostics/bandwidth');
|
||||||
|
const result = await response.json();
|
||||||
|
displayBandwidthStats(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch bandwidth:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to fetch bandwidth stats', 'error');
|
||||||
|
} finally {
|
||||||
|
// Restore button
|
||||||
|
buttonEl.disabled = false;
|
||||||
|
buttonEl.innerHTML = originalHtml;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/diagnostics/bandwidth');
|
||||||
|
const result = await response.json();
|
||||||
|
displayBandwidthStats(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch bandwidth:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to fetch bandwidth stats', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display diagnostic result
|
||||||
|
function displayDiagnosticResult(tool, result) {
|
||||||
|
const container = document.getElementById('diagnostics-results');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const resultId = `result-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const resultDiv = document.createElement('div');
|
||||||
|
resultDiv.id = resultId;
|
||||||
|
resultDiv.className = 'mb-4 p-4 theme-card rounded-lg';
|
||||||
|
|
||||||
|
const timestamp = new Date().toLocaleString();
|
||||||
|
const successClass = result.success ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
|
||||||
|
const successText = result.success ? 'Success' : 'Failed';
|
||||||
|
|
||||||
|
let content = `
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<h4 class="font-semibold">${tool} - ${timestamp}</h4>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="px-2 py-1 rounded text-sm font-semibold ${successClass}">${successText}</span>
|
||||||
|
<button id="close-btn-${resultId}" class="ml-2 px-2 py-1 text-xs bg-gray-500 text-white rounded hover:bg-gray-600">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
content += `<p class="text-red-600 dark:text-red-400 mb-2">Error: ${result.error}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.output) {
|
||||||
|
content += `<pre class="bg-black text-green-400 p-3 rounded text-sm overflow-x-auto">${escapeHtml(result.output)}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.results && Array.isArray(result.results)) {
|
||||||
|
content += `<div class="mt-2"><strong>Results:</strong><pre class="theme-glass p-2 rounded text-sm">${JSON.stringify(result.results, null, 2)}</pre></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.domain) {
|
||||||
|
content += `<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">Domain: ${result.domain}${result.type ? ` (${result.type})` : ''}${result.ip ? ` → ${result.ip}` : ''}${result.port ? `:${result.port}` : ''}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.responseTime) {
|
||||||
|
content += `<p class="text-sm text-gray-600 dark:text-gray-400">Response time: ${result.responseTime}ms</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultDiv.innerHTML = content;
|
||||||
|
|
||||||
|
// Attach close button event listener
|
||||||
|
const closeBtn = resultDiv.querySelector(`#close-btn-${resultId}`);
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
closeDiagnosticResult(resultId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
container.insertBefore(resultDiv, container.firstChild);
|
||||||
|
|
||||||
|
// Keep only last 10 results
|
||||||
|
while (container.children.length > 10) {
|
||||||
|
container.removeChild(container.lastChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display bandwidth stats
|
||||||
|
function displayBandwidthStats(result) {
|
||||||
|
const container = document.getElementById('bandwidth-stats');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (result.note) {
|
||||||
|
container.innerHTML = `<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">${result.note}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaces = Object.values(result.interfaces || {});
|
||||||
|
if (interfaces.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500">No network interfaces found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = interfaces.map(iface => `
|
||||||
|
<div class="mb-4 p-4 theme-card rounded-lg">
|
||||||
|
<h4 class="font-semibold mb-2">${iface.name}</h4>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
${iface.addresses.map(addr => `
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>${addr.address}</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">${addr.family} ${addr.internal ? '(internal)' : ''}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape HTML
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel stream
|
||||||
|
function cancelStream(resultId) {
|
||||||
|
console.log('Cancelling stream:', resultId);
|
||||||
|
const streamInfo = activeStreams.get(resultId);
|
||||||
|
if (streamInfo) {
|
||||||
|
streamInfo.cancelled = true;
|
||||||
|
if (streamInfo.reader) {
|
||||||
|
streamInfo.reader.cancel().catch(err => {
|
||||||
|
console.log('Stream cancellation error (expected):', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Update the UI to show cancelled state
|
||||||
|
const resultDiv = document.getElementById(resultId);
|
||||||
|
if (resultDiv) {
|
||||||
|
const outputContainer = resultDiv.querySelector('.streaming-output');
|
||||||
|
if (outputContainer) {
|
||||||
|
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||||
|
}
|
||||||
|
updateStreamingResultStatus(resultDiv, false);
|
||||||
|
}
|
||||||
|
activeStreams.delete(resultId);
|
||||||
|
} else {
|
||||||
|
console.log('Stream not found:', resultId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create streaming result container
|
||||||
|
function createStreamingResultContainer(tool, resultId, onCancel) {
|
||||||
|
const container = document.getElementById('diagnostics-results');
|
||||||
|
if (!container) return null;
|
||||||
|
|
||||||
|
const resultDiv = document.createElement('div');
|
||||||
|
resultDiv.id = resultId;
|
||||||
|
resultDiv.className = 'mb-4 p-4 theme-card rounded-lg';
|
||||||
|
|
||||||
|
const timestamp = new Date().toLocaleString();
|
||||||
|
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<h4 class="font-semibold">${tool} - ${timestamp} <span class="text-sm text-gray-600 dark:text-gray-400">(Streaming...)</span></h4>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="px-2 py-1 rounded text-sm font-semibold bg-yellow-500" style="color: var(--text-primary);">
|
||||||
|
<span class="inline-block animate-spin rounded-full h-3 w-3 border-t-2 border-yellow-800 dark:border-yellow-200 mr-1"></span>Running
|
||||||
|
</span>
|
||||||
|
${onCancel ? `<button id="cancel-btn-${resultId}" class="ml-2 px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">Cancel</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="streaming-output bg-black text-green-400 p-3 rounded text-sm overflow-x-auto font-mono max-h-64 overflow-y-auto" style="min-height: 100px;"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Attach event listener to cancel button
|
||||||
|
if (onCancel) {
|
||||||
|
const cancelBtn = resultDiv.querySelector(`#cancel-btn-${resultId}`);
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
cancelStream(resultId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.insertBefore(resultDiv, container.firstChild);
|
||||||
|
|
||||||
|
// Keep only last 5 streaming results
|
||||||
|
while (container.children.length > 5) {
|
||||||
|
container.removeChild(container.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append streaming output
|
||||||
|
function appendStreamingOutput(container, text, type) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = type === 'error' ? 'text-red-400' : 'text-green-400';
|
||||||
|
line.textContent = text;
|
||||||
|
container.appendChild(line);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update streaming result status
|
||||||
|
function updateStreamingResultStatus(resultDiv, success) {
|
||||||
|
if (!resultDiv) return;
|
||||||
|
|
||||||
|
const statusEl = resultDiv.querySelector('.font-semibold span');
|
||||||
|
const badgeEl = resultDiv.querySelector('.px-2.py-1');
|
||||||
|
const buttonContainer = resultDiv.querySelector('.flex.items-center.gap-2');
|
||||||
|
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '(Complete)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badgeEl) {
|
||||||
|
badgeEl.className = success
|
||||||
|
? 'px-2 py-1 rounded text-sm font-semibold bg-green-500'
|
||||||
|
: 'px-2 py-1 rounded text-sm font-semibold bg-red-500';
|
||||||
|
badgeEl.style.color = 'var(--text-primary)';
|
||||||
|
badgeEl.innerHTML = success ? 'Success' : 'Failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace cancel button with close button
|
||||||
|
if (buttonContainer) {
|
||||||
|
const cancelBtn = buttonContainer.querySelector('button[id^="cancel-btn-"]');
|
||||||
|
if (cancelBtn) {
|
||||||
|
const resultId = resultDiv.id;
|
||||||
|
cancelBtn.remove();
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.id = `close-btn-${resultId}`;
|
||||||
|
closeBtn.className = 'ml-2 px-2 py-1 text-xs bg-gray-500 text-white rounded hover:bg-gray-600';
|
||||||
|
closeBtn.textContent = 'Close';
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
closeDiagnosticResult(resultId);
|
||||||
|
});
|
||||||
|
buttonContainer.appendChild(closeBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close/remove diagnostic result
|
||||||
|
function closeDiagnosticResult(resultId) {
|
||||||
|
const resultDiv = document.getElementById(resultId);
|
||||||
|
if (resultDiv && resultDiv.parentNode) {
|
||||||
|
resultDiv.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup Enter key handlers for diagnostics forms
|
||||||
|
function setupDiagnosticsEnterHandlers() {
|
||||||
|
// Return early if handlers have already been set up
|
||||||
|
if (handlersSetup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS Lookup - Enter on domain input
|
||||||
|
const dnsDomainInput = document.getElementById('dns-lookup-domain');
|
||||||
|
if (dnsDomainInput) {
|
||||||
|
dnsDomainInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
runDnsLookup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping - Enter on target or count input
|
||||||
|
const pingTargetInput = document.getElementById('ping-target');
|
||||||
|
const pingCountInput = document.getElementById('ping-count');
|
||||||
|
if (pingTargetInput) {
|
||||||
|
pingTargetInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
runPing();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pingCountInput) {
|
||||||
|
pingCountInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
runPing();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traceroute - Enter on target input
|
||||||
|
const tracerouteTargetInput = document.getElementById('traceroute-target');
|
||||||
|
if (tracerouteTargetInput) {
|
||||||
|
tracerouteTargetInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
runTraceroute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection Test - Enter on domain or port input
|
||||||
|
const connectionDomainInput = document.getElementById('connection-domain');
|
||||||
|
const connectionPortInput = document.getElementById('connection-port');
|
||||||
|
if (connectionDomainInput) {
|
||||||
|
connectionDomainInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
testConnection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (connectionPortInput) {
|
||||||
|
connectionPortInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
testConnection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark handlers as set up
|
||||||
|
handlersSetup = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render diagnostics
|
||||||
|
function renderDiagnostics() {
|
||||||
|
// Load bandwidth stats on render
|
||||||
|
fetchBandwidth();
|
||||||
|
// Setup Enter key handlers
|
||||||
|
setupDiagnosticsEnterHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.runDnsLookup = runDnsLookup;
|
||||||
|
window.runPing = runPing;
|
||||||
|
window.runTraceroute = runTraceroute;
|
||||||
|
window.testConnection = testConnection;
|
||||||
|
window.fetchBandwidth = fetchBandwidth;
|
||||||
|
window.renderDiagnostics = renderDiagnostics;
|
||||||
|
window.cancelStream = cancelStream;
|
||||||
|
window.closeDiagnosticResult = closeDiagnosticResult;
|
||||||
|
|
||||||
|
// Setup Enter key handlers when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', setupDiagnosticsEnterHandlers);
|
||||||
|
} else {
|
||||||
|
// DOM is already loaded
|
||||||
|
setupDiagnosticsEnterHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Domains UI functions
|
||||||
|
function renderDnsConflicts() {
|
||||||
|
if (window.genericFilter) window.genericFilter('dns-conflicts');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
const modal = document.getElementById('addDomainModal');
|
||||||
|
if (modal) modal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAddDomain() {
|
||||||
|
const domainEl = document.getElementById('modal-domain');
|
||||||
|
const hashEl = document.getElementById('modal-hash');
|
||||||
|
const sslEl = document.getElementById('modal-ssl');
|
||||||
|
if (!domainEl || !hashEl) return;
|
||||||
|
|
||||||
|
const domain = domainEl.value.trim();
|
||||||
|
const hash = hashEl.value.trim();
|
||||||
|
const ssl = sslEl ? sslEl.checked : false;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/add-domain', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain, hash, ssl })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Domain added successfully');
|
||||||
|
const modal = document.getElementById('addDomainModal');
|
||||||
|
if (modal) modal.close();
|
||||||
|
domainEl.value = '';
|
||||||
|
hashEl.value = '';
|
||||||
|
if (sslEl) sslEl.checked = false;
|
||||||
|
if (window.genericFetch) window.genericFetch('domains', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add domain:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to add domain: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDomain(domain) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Remove ${domain}?`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/remove-domain', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Domain removed successfully');
|
||||||
|
if (window.genericFetch) window.genericFetch('domains', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove domain:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to remove domain: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.renderDnsConflicts = renderDnsConflicts;
|
||||||
|
window.openAddModal = openAddModal;
|
||||||
|
window.submitAddDomain = submitAddDomain;
|
||||||
|
window.removeDomain = removeDomain;
|
||||||
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Health monitoring UI functions
|
||||||
|
|
||||||
|
let healthData = null;
|
||||||
|
let healthUpdateInterval = null;
|
||||||
|
|
||||||
|
// Fetch health data
|
||||||
|
async function fetchHealth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch health data');
|
||||||
|
}
|
||||||
|
healthData = await response.json();
|
||||||
|
return healthData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch health:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to load health data', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render health dashboard
|
||||||
|
async function renderHealth() {
|
||||||
|
const data = await fetchHealth();
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
updateHealthStatus(data);
|
||||||
|
renderServiceCards(data);
|
||||||
|
updateHealthHistory(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update health status display
|
||||||
|
function updateHealthStatus(data) {
|
||||||
|
const statusEl = document.getElementById('health-status');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = data.status === 'healthy' ? 'Healthy' : 'Degraded';
|
||||||
|
statusEl.className = data.status === 'healthy'
|
||||||
|
? 'text-2xl font-bold text-green-600 dark:text-green-400'
|
||||||
|
: 'text-2xl font-bold text-yellow-600 dark:text-yellow-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
const uptimeEl = document.getElementById('health-uptime');
|
||||||
|
if (uptimeEl && data.uptime) {
|
||||||
|
uptimeEl.textContent = window.formatUptime ? window.formatUptime(data.uptime) : `${Math.floor(data.uptime / 1000)}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render service status cards
|
||||||
|
function renderServiceCards(data) {
|
||||||
|
const services = [
|
||||||
|
{ key: 'dns', name: 'DNS Service', icon: '🌐' },
|
||||||
|
{ key: 'proxy', name: 'Proxy Service', icon: '🔒' },
|
||||||
|
{ key: 'swarm', name: 'Swarm', icon: '🔗' },
|
||||||
|
{ key: 'corestore', name: 'Corestore', icon: '💾' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const container = document.getElementById('health-services');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = services.map(service => {
|
||||||
|
const serviceData = data.services?.[service.key] || data.dependencies?.[service.key];
|
||||||
|
const healthy = serviceData?.healthy !== false;
|
||||||
|
const enabled = serviceData?.enabled !== false;
|
||||||
|
const statusColor = healthy ? 'green' : 'red';
|
||||||
|
const statusText = healthy ? 'Healthy' : 'Unhealthy';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rounded-lg shadow-md p-4 theme-card">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-2xl">${service.icon}</span>
|
||||||
|
<h3 class="text-lg font-semibold">${service.name}</h3>
|
||||||
|
</div>
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-semibold ${
|
||||||
|
healthy ? 'bg-green-500' :
|
||||||
|
'bg-red-500'
|
||||||
|
}" style="color: var(--text-primary);">
|
||||||
|
${statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
${enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
${serviceData?.details ? ` • ${JSON.stringify(serviceData.details).replace(/[{}"]/g, '').substring(0, 50)}...` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update health history
|
||||||
|
function updateHealthHistory(data) {
|
||||||
|
// Health history tracking removed - chart no longer displayed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render health history chart - removed, chart no longer displayed
|
||||||
|
function renderHealthHistoryChart() {
|
||||||
|
// Health history chart removed from stats page
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start health updates
|
||||||
|
function startHealthUpdates() {
|
||||||
|
if (healthUpdateInterval) return;
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
renderHealth();
|
||||||
|
|
||||||
|
// Update every 5 seconds
|
||||||
|
healthUpdateInterval = setInterval(() => {
|
||||||
|
renderHealth();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop health updates
|
||||||
|
function stopHealthUpdates() {
|
||||||
|
if (healthUpdateInterval) {
|
||||||
|
clearInterval(healthUpdateInterval);
|
||||||
|
healthUpdateInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.renderHealth = renderHealth;
|
||||||
|
window.startHealthUpdates = startHealthUpdates;
|
||||||
|
window.stopHealthUpdates = stopHealthUpdates;
|
||||||
|
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
// Holesail UI functions - Create server/client modals and actions
|
||||||
|
|
||||||
|
// Open create Holesail server modal
|
||||||
|
function openCreateHolesailModal() {
|
||||||
|
const nameEl = document.getElementById('holesail-name');
|
||||||
|
const portEl = document.getElementById('holesail-port');
|
||||||
|
const hostEl = document.getElementById('holesail-host');
|
||||||
|
const keyEl = document.getElementById('holesail-key');
|
||||||
|
const domainEl = document.getElementById('holesail-domain');
|
||||||
|
const secureEl = document.getElementById('holesail-secure');
|
||||||
|
const udpEl = document.getElementById('holesail-udp');
|
||||||
|
const logEl = document.getElementById('holesail-log');
|
||||||
|
|
||||||
|
if (nameEl) nameEl.value = '';
|
||||||
|
if (portEl) portEl.value = '';
|
||||||
|
if (hostEl) hostEl.value = '127.0.0.1';
|
||||||
|
if (keyEl) keyEl.value = '';
|
||||||
|
if (domainEl) domainEl.value = '';
|
||||||
|
if (secureEl) secureEl.checked = true;
|
||||||
|
if (udpEl) udpEl.checked = false;
|
||||||
|
if (logEl) logEl.value = '1';
|
||||||
|
|
||||||
|
const modal = document.getElementById('createHolesailModal');
|
||||||
|
if (modal) modal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit create Holesail server
|
||||||
|
async function submitCreateHolesail() {
|
||||||
|
const createBtn = document.getElementById('create-holesail-btn');
|
||||||
|
if (!createBtn) return;
|
||||||
|
|
||||||
|
const originalText = createBtn.innerHTML;
|
||||||
|
createBtn.disabled = true;
|
||||||
|
createBtn.innerHTML = 'Creating... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span>';
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
name: document.getElementById('holesail-name')?.value || '',
|
||||||
|
port: parseInt(document.getElementById('holesail-port')?.value || '0'),
|
||||||
|
host: document.getElementById('holesail-host')?.value || '127.0.0.1',
|
||||||
|
key: document.getElementById('holesail-key')?.value || '',
|
||||||
|
domain: document.getElementById('holesail-domain')?.value || '',
|
||||||
|
secure: document.getElementById('holesail-secure')?.checked || false,
|
||||||
|
udp: document.getElementById('holesail-udp')?.checked || false,
|
||||||
|
log: parseInt(document.getElementById('holesail-log')?.value || '1')
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/holesail-create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(opts)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Holesail server created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('createHolesailModal');
|
||||||
|
if (modal) modal.close();
|
||||||
|
|
||||||
|
if (window.genericFetch && window.activeTab === 'host') {
|
||||||
|
window.genericFetch('host-servers', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to create Holesail server: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
createBtn.disabled = false;
|
||||||
|
createBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open create client modal
|
||||||
|
async function openCreateClientModal() {
|
||||||
|
if (!window.domainsData && window.genericFetch) {
|
||||||
|
await window.genericFetch('domains', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = document.getElementById('client-domain');
|
||||||
|
if (select && window.domainsData) {
|
||||||
|
// Filter to only show domains where user is owner
|
||||||
|
const ownedDomains = window.domainsData.filter(d => d.isOwner === true);
|
||||||
|
if (ownedDomains.length === 0) {
|
||||||
|
select.innerHTML = '<option value="">No owned domains available</option>';
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('You must own a domain to create a client', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
select.innerHTML = ownedDomains.map(d => `<option value="${d.domain}">${d.domain}</option>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceNameEl = document.getElementById('client-service-name');
|
||||||
|
const keyEl = document.getElementById('client-key');
|
||||||
|
const portEl = document.getElementById('client-port');
|
||||||
|
const protocolEl = document.getElementById('client-protocol');
|
||||||
|
|
||||||
|
if (serviceNameEl) serviceNameEl.value = '';
|
||||||
|
if (keyEl) keyEl.value = '';
|
||||||
|
if (portEl) portEl.value = '';
|
||||||
|
if (protocolEl) protocolEl.value = 'tcp';
|
||||||
|
|
||||||
|
const modal = document.getElementById('createClientModal');
|
||||||
|
if (modal) modal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit create client
|
||||||
|
async function submitCreateClient() {
|
||||||
|
const domainEl = document.getElementById('client-domain');
|
||||||
|
const serviceNameEl = document.getElementById('client-service-name');
|
||||||
|
const keyEl = document.getElementById('client-key');
|
||||||
|
const portEl = document.getElementById('client-port');
|
||||||
|
const protocolEl = document.getElementById('client-protocol');
|
||||||
|
|
||||||
|
const domain = domainEl?.value || '';
|
||||||
|
const serviceName = serviceNameEl?.value || '';
|
||||||
|
const key = keyEl?.value || '';
|
||||||
|
const port = parseInt(portEl?.value || '0');
|
||||||
|
const protocol = protocolEl?.value || 'tcp';
|
||||||
|
|
||||||
|
if (!domain || !serviceName || !key || isNaN(port)) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('All fields are required', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ownership
|
||||||
|
const domainData = window.domainsData?.find(d => d.domain === domain);
|
||||||
|
if (!domainData || !domainData.isOwner) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('You must own this domain to create a client', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBtn = document.getElementById('create-client-btn');
|
||||||
|
if (!createBtn) return;
|
||||||
|
|
||||||
|
const originalText = createBtn.innerHTML;
|
||||||
|
createBtn.disabled = true;
|
||||||
|
createBtn.innerHTML = 'Creating... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/holesail-client-create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain, serviceName, key, port, protocol })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Holesail client created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('createClientModal');
|
||||||
|
if (modal) modal.close();
|
||||||
|
|
||||||
|
if (window.genericFetch && window.activeTab === 'host') {
|
||||||
|
window.genericFetch('host-clients', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to create Holesail client: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
createBtn.disabled = false;
|
||||||
|
createBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart Holesail server
|
||||||
|
async function restartHolesailServer(id) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Restart server ${id}?`, async () => {
|
||||||
|
if (window.pendingServerRestarts) {
|
||||||
|
window.pendingServerRestarts.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-servers');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/holesail-restart', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to restart Holesail server: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
if (window.pendingServerRestarts) {
|
||||||
|
window.pendingServerRestarts.delete(id);
|
||||||
|
}
|
||||||
|
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-servers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Holesail server
|
||||||
|
function deleteHolesailServer(id) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Delete server ${id}?`, async () => {
|
||||||
|
if (window.pendingServerDeletions) {
|
||||||
|
window.pendingServerDeletions.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-servers');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/holesail-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to delete Holesail server: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
if (window.pendingServerDeletions) {
|
||||||
|
window.pendingServerDeletions.delete(id);
|
||||||
|
}
|
||||||
|
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-servers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart Holesail client
|
||||||
|
async function restartHolesailClient(id) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Restart client ${id}?`, async () => {
|
||||||
|
if (window.pendingClientRestarts) {
|
||||||
|
window.pendingClientRestarts.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-clients');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/holesail-client-restart', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to restart Holesail client: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
if (window.pendingClientRestarts) {
|
||||||
|
window.pendingClientRestarts.delete(id);
|
||||||
|
}
|
||||||
|
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-clients');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Holesail client
|
||||||
|
function deleteHolesailClient(id) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Delete client ${id}?`, async () => {
|
||||||
|
if (window.pendingClientDeletions) {
|
||||||
|
window.pendingClientDeletions.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-clients');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/holesail-client-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to delete Holesail client: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
if (window.pendingClientDeletions) {
|
||||||
|
window.pendingClientDeletions.delete(id);
|
||||||
|
}
|
||||||
|
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-clients');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open Holesail log modal
|
||||||
|
function openHolesailLog(id, name) {
|
||||||
|
const titleEl = document.getElementById('holesail-log-title');
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = `Logs for ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.holesailTerm) {
|
||||||
|
if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') {
|
||||||
|
window.holesailTerm = new Terminal();
|
||||||
|
window.holesailFitAddon = new FitAddon.FitAddon();
|
||||||
|
window.holesailTerm.loadAddon(window.holesailFitAddon);
|
||||||
|
} else {
|
||||||
|
console.error('Terminal or FitAddon not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('holesail-terminal');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
window.holesailTerm.open(container);
|
||||||
|
window.holesailTerm.reset();
|
||||||
|
|
||||||
|
const buffer = window.holesailLogBuffers?.get(id) || [];
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
window.holesailTerm.writeln('');
|
||||||
|
window.holesailTerm.writeln('No logs available yet.');
|
||||||
|
window.holesailTerm.writeln('Logs will appear here as they are generated.');
|
||||||
|
window.holesailTerm.writeln('');
|
||||||
|
} else {
|
||||||
|
buffer.forEach(line => window.holesailTerm.writeln(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.holesailFitAddon) {
|
||||||
|
window.holesailFitAddon.fit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.currentOpenHolesailId = id;
|
||||||
|
|
||||||
|
const modal = document.getElementById('holesailLogModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.showModal();
|
||||||
|
modal.addEventListener('close', () => {
|
||||||
|
window.currentOpenHolesailId = null;
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service Subscription functions
|
||||||
|
let subscriptionDomainsData = [];
|
||||||
|
let subscriptionList = [];
|
||||||
|
let subscribeAllDomains = [];
|
||||||
|
|
||||||
|
async function openServiceSubscriptionModal() {
|
||||||
|
const modal = document.getElementById('serviceSubscriptionModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
// Load subscriptions
|
||||||
|
try {
|
||||||
|
const subsResponse = await fetch('/api/service-subscriptions');
|
||||||
|
if (subsResponse.ok) {
|
||||||
|
subscriptionList = await subsResponse.json();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading subscriptions:', err);
|
||||||
|
subscriptionList = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load subscribe-all domains
|
||||||
|
try {
|
||||||
|
const subscribeAllResponse = await fetch('/api/subscribe-all-domains');
|
||||||
|
if (subscribeAllResponse.ok) {
|
||||||
|
subscribeAllDomains = await subscribeAllResponse.json();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading subscribe-all domains:', err);
|
||||||
|
subscribeAllDomains = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load domains with services
|
||||||
|
try {
|
||||||
|
const domainsResponse = await fetch('/api/resolved-domains');
|
||||||
|
if (domainsResponse.ok) {
|
||||||
|
const allDomains = await domainsResponse.json();
|
||||||
|
// Fetch services for each domain, but exclude domains the user owns
|
||||||
|
subscriptionDomainsData = [];
|
||||||
|
for (const domain of allDomains) {
|
||||||
|
// Skip domains where the user is the owner (isOwner === true)
|
||||||
|
if (domain.isOwner === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (domain.hash && domain.hash !== 'none' && domain.hash !== 'internal') {
|
||||||
|
try {
|
||||||
|
const servicesResponse = await fetch(`/api/domain-services?domain=${encodeURIComponent(domain.domain)}`);
|
||||||
|
if (servicesResponse.ok) {
|
||||||
|
const services = await servicesResponse.json();
|
||||||
|
if (services && services.length > 0) {
|
||||||
|
subscriptionDomainsData.push({
|
||||||
|
domain: domain.domain,
|
||||||
|
hash: domain.hash,
|
||||||
|
services: services
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error loading services for ${domain.domain}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading domains:', err);
|
||||||
|
subscriptionDomainsData = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSubscriptionDomains();
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSubscriptionDomains() {
|
||||||
|
renderSubscriptionDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubscriptionDomains() {
|
||||||
|
const container = document.getElementById('subscription-domains-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const searchTerm = document.getElementById('subscription-search')?.value.toLowerCase() || '';
|
||||||
|
const filtered = subscriptionDomainsData.filter(d =>
|
||||||
|
d.domain.toLowerCase().includes(searchTerm) ||
|
||||||
|
d.services.some(s => s.name.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No remote domains with services found.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = filtered.map(domainData => {
|
||||||
|
const servicesHtml = domainData.services.map(service => {
|
||||||
|
const isSubscribed = subscriptionList.some(sub =>
|
||||||
|
sub.domain === domainData.domain && sub.serviceName === service.name
|
||||||
|
);
|
||||||
|
return `
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg mb-2">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">${service.name}</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Port: ${service.port} | Protocol: ${service.protocol}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick="${isSubscribed ? `unsubscribeFromService('${domainData.domain}', '${service.name}')` : `subscribeToService('${domainData.domain}', '${service.name}', '${service.key}', ${service.port}, '${service.protocol}')`}"
|
||||||
|
class="px-4 py-2 ${isSubscribed ? 'bg-red-500 hover:bg-red-600' : 'bg-primary hover:bg-primary-hover'} text-white rounded"
|
||||||
|
>
|
||||||
|
${isSubscribed ? 'Unsubscribe' : 'Subscribe'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Check if subscribeAll is enabled for this domain
|
||||||
|
const isSubscribeAll = subscribeAllDomains.includes(domainData.domain);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="border border-gray-300 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h4 class="text-lg font-semibold">${domainData.domain}</h4>
|
||||||
|
${isSubscribeAll ? '<span class="px-2 py-1 text-xs bg-green-500 text-white rounded">Auto-Subscribe Enabled</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick="${isSubscribeAll ? `unsubscribeAllFromDomain('${domainData.domain}')` : `subscribeAllToDomain('${domainData.domain}')`}"
|
||||||
|
class="px-3 py-1 text-sm ${isSubscribeAll ? 'bg-red-500 hover:bg-red-600' : 'bg-green-600 hover:bg-green-700'} text-white rounded"
|
||||||
|
>
|
||||||
|
${isSubscribeAll ? 'Disable Auto-Subscribe' : 'Enable Auto-Subscribe'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
${servicesHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribeToService(domain, serviceName, key, port, protocol) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/service-subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain, serviceName, key, port, protocol })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Subscribed to ${domain}/${serviceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload subscriptions and re-render
|
||||||
|
const subsResponse = await fetch('/api/service-subscriptions');
|
||||||
|
if (subsResponse.ok) {
|
||||||
|
subscriptionList = await subsResponse.json();
|
||||||
|
}
|
||||||
|
renderSubscriptionDomains();
|
||||||
|
|
||||||
|
if (window.genericFetch && window.activeTab === 'host') {
|
||||||
|
window.genericFetch('host-clients', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to subscribe: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsubscribeFromService(domain, serviceName) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/service-unsubscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain, serviceName })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Unsubscribed from ${domain}/${serviceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload subscriptions and re-render
|
||||||
|
const subsResponse = await fetch('/api/service-subscriptions');
|
||||||
|
if (subsResponse.ok) {
|
||||||
|
subscriptionList = await subsResponse.json();
|
||||||
|
}
|
||||||
|
renderSubscriptionDomains();
|
||||||
|
|
||||||
|
if (window.genericFetch && window.activeTab === 'host') {
|
||||||
|
window.genericFetch('host-clients', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to unsubscribe: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribeAllToDomain(domain) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/subscribe-all', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Enabled auto-subscribe for ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload subscribe-all domains and re-render
|
||||||
|
const subscribeAllResponse = await fetch('/api/subscribe-all-domains');
|
||||||
|
if (subscribeAllResponse.ok) {
|
||||||
|
subscribeAllDomains = await subscribeAllResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also subscribe to existing services
|
||||||
|
const domainData = subscriptionDomainsData.find(d => d.domain === domain);
|
||||||
|
if (domainData && domainData.services) {
|
||||||
|
for (const service of domainData.services) {
|
||||||
|
if (!subscriptionList.some(sub => sub.domain === domain && sub.serviceName === service.name)) {
|
||||||
|
await subscribeToService(domain, service.name, service.key, service.port, service.protocol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload subscriptions
|
||||||
|
const subsResponse = await fetch('/api/service-subscriptions');
|
||||||
|
if (subsResponse.ok) {
|
||||||
|
subscriptionList = await subsResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSubscriptionDomains();
|
||||||
|
|
||||||
|
if (window.genericFetch && window.activeTab === 'host') {
|
||||||
|
window.genericFetch('host-clients', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to enable auto-subscribe: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsubscribeAllFromDomain(domain) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/unsubscribe-all', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Disabled auto-subscribe for ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload subscribe-all domains and re-render
|
||||||
|
const subscribeAllResponse = await fetch('/api/subscribe-all-domains');
|
||||||
|
if (subscribeAllResponse.ok) {
|
||||||
|
subscribeAllDomains = await subscribeAllResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSubscriptionDomains();
|
||||||
|
|
||||||
|
if (window.genericFetch && window.activeTab === 'host') {
|
||||||
|
window.genericFetch('host-clients', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to disable auto-subscribe: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.openCreateHolesailModal = openCreateHolesailModal;
|
||||||
|
window.submitCreateHolesail = submitCreateHolesail;
|
||||||
|
window.openCreateClientModal = openCreateClientModal;
|
||||||
|
window.submitCreateClient = submitCreateClient;
|
||||||
|
window.restartHolesailServer = restartHolesailServer;
|
||||||
|
window.deleteHolesailServer = deleteHolesailServer;
|
||||||
|
window.restartHolesailClient = restartHolesailClient;
|
||||||
|
window.deleteHolesailClient = deleteHolesailClient;
|
||||||
|
window.openHolesailLog = openHolesailLog;
|
||||||
|
window.openServiceSubscriptionModal = openServiceSubscriptionModal;
|
||||||
|
window.filterSubscriptionDomains = filterSubscriptionDomains;
|
||||||
|
window.subscribeToService = subscribeToService;
|
||||||
|
window.unsubscribeFromService = unsubscribeFromService;
|
||||||
|
window.subscribeAllToDomain = subscribeAllToDomain;
|
||||||
|
window.unsubscribeAllFromDomain = unsubscribeAllFromDomain;
|
||||||
|
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
// Info Modal System
|
||||||
|
const infoContent = {
|
||||||
|
'domains': {
|
||||||
|
title: 'Domains',
|
||||||
|
description: 'Manage domains in the P2NS network',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'The Domains tab shows all domains registered in the P2NS network. Domains with 🏠 are your local claims that have been validated by the network.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Local Claims',
|
||||||
|
content: 'Local claims are domains you own and have registered. These are marked with a 🏠 icon. Only local claims can be removed from the system.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Adding Domains',
|
||||||
|
content: 'Click "Add Domain" to register a new domain. You\'ll need to provide the domain name and its corresponding hash.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Removing Domains',
|
||||||
|
content: 'You can only remove domains that you own (local claims). Click the "Remove" button next to a local domain to remove it.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
content: 'Use the search box to filter domains by name or hash.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'local-dns': {
|
||||||
|
title: 'Custom Local DNS Records',
|
||||||
|
description: 'Manage custom DNS records served outside of P2NS assignments',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'Custom Local DNS Records allow you to define DNS entries that are served independently of the P2NS system. These records take precedence over P2NS assignments for local resolution.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Supported Record Types',
|
||||||
|
content: 'You can create A, AAAA, CNAME, MX, TXT, SRV, SOA, CAA, NS, and PTR records. Each record type has specific fields that need to be filled.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'TTL (Time To Live)',
|
||||||
|
content: 'TTL determines how long DNS resolvers should cache the record. Default is 3600 seconds (1 hour).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Managing Records',
|
||||||
|
content: 'Use "Add Record" to create new entries, "Edit" to modify existing ones, and "Delete" to remove records.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'dns-conflicts': {
|
||||||
|
title: 'DNS Conflict Selector',
|
||||||
|
description: 'Choose between P2P and public DNS for conflicting domains',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'When a domain has both P2P and public DNS records, you can choose which one to use for resolution.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'P2P Mode',
|
||||||
|
content: 'P2P mode uses the peer-to-peer network resolution, which connects directly to other nodes in the network.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Public Mode',
|
||||||
|
content: 'Public mode uses traditional DNS resolution through public DNS servers.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Switching Modes',
|
||||||
|
content: 'Use the toggle switch to switch between P2P and Public modes for each conflicting domain.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'entries': {
|
||||||
|
title: 'Autopass Entries',
|
||||||
|
description: 'View the P2P network ledger',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'Autopass Entries show the raw data from Autopass cores serving the P2P Network. This is the ledger of the system containing all votes and claims.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ledger Data',
|
||||||
|
content: 'Each entry represents a record in the distributed ledger. The Key-Value pairs show the actual data stored in the network.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Votes and Claims',
|
||||||
|
content: 'The ledger contains voting records and domain claims that have been validated by the network consensus.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
content: 'Use the search box to find specific entries by key or value.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'peers': {
|
||||||
|
title: 'Connected Peers',
|
||||||
|
description: 'View and manage peer connections',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'The Peers tab shows all nodes currently connected to your P2NS instance in the peer-to-peer network.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Peer Connections',
|
||||||
|
content: 'Each peer represents another node in the P2NS network. These connections enable distributed domain resolution and data synchronization.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Network Topology',
|
||||||
|
content: 'The peer list shows the current state of your network connections. Peers are identified by their unique keys.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Connection Status',
|
||||||
|
content: 'The count next to "Connected Peers" shows how many active peer connections you currently have.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'certs': {
|
||||||
|
title: 'Domain Certificates',
|
||||||
|
description: 'Manage SSL/TLS certificates for domains',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'The Certificates tab manages SSL/TLS certificates for domains in your P2NS network.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Certificate Generation',
|
||||||
|
content: 'Enter a domain name and click "Generate Cert" to create a new certificate. Certificates are automatically signed by the root CA.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Certificate Management',
|
||||||
|
content: 'You can view certificate details, regenerate certificates, or delete them. Regenerating creates a new certificate with updated validity.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Certificate Details',
|
||||||
|
content: 'Click on a certificate name to view its full details including issuer, validity dates, and fingerprint.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'ca-management': {
|
||||||
|
title: 'CA Management',
|
||||||
|
description: 'Manage the Certificate Authority',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'CA Management controls the Root Certificate Authority that signs all domain certificates.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Regenerate Root CA',
|
||||||
|
content: 'Regenerating the Root CA creates a new CA certificate and key. This invalidates all existing certificates, which will need to be regenerated.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Install Root CA',
|
||||||
|
content: 'Installing the Root CA adds it to your system\'s trusted certificate store, allowing browsers to trust certificates signed by this CA.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Security Note',
|
||||||
|
content: 'Only regenerate the CA if necessary, as it will require reinstalling the CA and regenerating all certificates.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'interfaces': {
|
||||||
|
title: 'Virtual Interfaces',
|
||||||
|
description: 'Manage virtual network interfaces',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'Virtual Interfaces are network interfaces created for each domain to enable local routing and DNS resolution.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Interface Assignment',
|
||||||
|
content: 'Each domain gets assigned a virtual IP address on a virtual interface. This allows local applications to connect to P2P domains.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Interface List',
|
||||||
|
content: 'The table shows all active virtual interfaces with their associated domains and IP addresses. Use the search box to filter interfaces, and pagination controls to navigate through the list.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'holesail-servers': {
|
||||||
|
title: 'Holesail Servers',
|
||||||
|
description: 'Manage Holesail tunnel servers',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'Holesail Servers create tunnels that allow external connections to reach services on your network.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Server Configuration',
|
||||||
|
content: 'Each server listens on a specific port and can be configured with a name, host, key, and protocol (TCP/UDP).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Server Status',
|
||||||
|
content: 'The status column shows whether a server is running, stopped, or in an error state.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Logs',
|
||||||
|
content: 'Click on a server name to view its logs in real-time. This helps with debugging connection issues.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Restart/Delete',
|
||||||
|
content: 'Use "Restart" to restart a server or "Delete" to permanently remove it.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'holesail-clients': {
|
||||||
|
title: 'Holesail Clients',
|
||||||
|
description: 'Manage Holesail tunnel clients',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'Holesail Clients create outbound tunnels to connect to external Holesail servers.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Client Configuration',
|
||||||
|
content: 'Each client connects to a domain using a key and port. The client establishes a tunnel to the server.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Client Status',
|
||||||
|
content: 'The status column shows whether a client is running, stopped, or in an error state.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Creating Clients',
|
||||||
|
content: 'Use "Create Client" to add a new client. You\'ll need the domain, key, and port from the server.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Restart/Delete',
|
||||||
|
content: 'Use "Restart" to restart a client or "Delete" to permanently remove it.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'logs': {
|
||||||
|
title: 'System Logs',
|
||||||
|
description: 'View real-time system logs',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'The Logs tab shows real-time logs from your P2NS instance. Logs are displayed in a terminal interface.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Log Levels',
|
||||||
|
content: 'Logs are color-coded by level: INFO (normal), WARN (yellow), ERROR (red), and DEBUG (gray).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Log Buffer',
|
||||||
|
content: 'The terminal maintains a buffer of the most recent log messages. Older logs are automatically removed to manage memory.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Real-time Updates',
|
||||||
|
content: 'Logs are updated in real-time via WebSocket connections. If the WebSocket is disconnected, the logs will pause until reconnection.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'stats': {
|
||||||
|
title: 'Statistics',
|
||||||
|
description: 'View system and network statistics',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'The Stats tab provides detailed statistics about your P2NS instance including system performance, network activity, and resource usage.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Real-time Data',
|
||||||
|
content: 'Statistics are updated in real-time. Enable auto-refresh to keep data current.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Historical Data',
|
||||||
|
content: 'Charts show historical trends over time. Use the time range selector to adjust the view window.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Export Data',
|
||||||
|
content: 'Use "Export Data" to download current statistics as JSON for analysis.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'settings': {
|
||||||
|
title: 'Settings',
|
||||||
|
description: 'Configure P2NS system settings',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'The Settings tab allows you to configure various aspects of your P2NS instance.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Live Reload',
|
||||||
|
content: 'Settings marked with "Live Reload" are applied immediately without requiring a restart.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Restart Required',
|
||||||
|
content: 'Settings marked with "Requires Restart" will only take effect after restarting the P2NS service.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Subnet Configuration',
|
||||||
|
content: 'Manage IP subnet ranges used for virtual interface assignments. Add, edit, or delete subnet configurations.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Saving Settings',
|
||||||
|
content: 'Click "Save Settings" to apply your changes. You\'ll be notified which settings require a restart.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'subnets': {
|
||||||
|
title: 'Subnet Configuration',
|
||||||
|
description: 'Manage IP subnet ranges',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'Subnets define IP address ranges used for virtual interface assignments. Each domain gets assigned an IP from the configured subnets.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Adding Subnets',
|
||||||
|
content: 'Click "Add Subnet" to create a new subnet. The system will suggest a non-conflicting subnet automatically.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IP Capacity',
|
||||||
|
content: 'The IP Capacity Estimator shows total available IPs, currently used IPs, and remaining capacity.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Subnet Management',
|
||||||
|
content: 'Use "Edit" to modify subnet settings or "Delete" to remove a subnet. Changes require a restart to fully apply.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'health': {
|
||||||
|
title: 'Health Monitoring',
|
||||||
|
description: 'Monitor system health and service status',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'The Health tab provides real-time monitoring of all system services including DNS, Proxy, Swarm, and Corestore.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Service Status',
|
||||||
|
content: 'Service status cards show the health of each component. Green indicates healthy, red indicates issues.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Health History',
|
||||||
|
content: 'The health history chart shows the status of services over time, helping you identify patterns and issues.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Network Diagnostics',
|
||||||
|
content: 'Use the diagnostic tools below to test DNS resolution, ping connectivity, traceroute paths, and connection tests.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Auto-refresh',
|
||||||
|
content: 'Enable auto-refresh to keep health data updated in real-time. Updates occur every 5 seconds.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'plugins': {
|
||||||
|
title: 'Plugins',
|
||||||
|
description: 'Manage plugins and their registered actions and settings',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'The Plugins tab shows all plugins loaded from the plugin-sites/ directory. Each plugin can register actions (executable functions) and settings (configurable values) that appear in the admin panel.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Plugin Cards',
|
||||||
|
content: 'Each plugin is displayed in a card showing its name, version, description, and status. Plugins can have handlers (dynamic request processing), web UIs (www/ directory), and databases (HyperDB integration).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
content: 'Plugins can register actions that can be executed from the admin panel. Click an action button to execute it. Actions can perform operations like resets, statistics gathering, or other plugin-specific functions.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Settings',
|
||||||
|
content: 'Plugins can register settings that appear as form fields in the admin panel. Configure plugin behavior by modifying these settings and clicking "Save Settings". Settings support various types: strings, numbers, booleans, selects, and textareas.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Live Reload',
|
||||||
|
content: 'Click the "Restart" button on any plugin card to reload that plugin without restarting P2NS. This is useful for testing plugin changes during development. The plugin will be shut down, its code reloaded, and re-initialized.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Search',
|
||||||
|
content: 'Use the search box to filter plugins by name, domain, description, author, or version.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'backups': {
|
||||||
|
title: 'Backup & Restore',
|
||||||
|
description: 'Manage system backups and restore from previous states',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'The Backups tab allows you to create, restore, and manage backups of your P2NS configuration and data.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Creating Backups',
|
||||||
|
content: 'Click "Create Backup" to manually create a backup. Backups include domains, local DNS records, and selector cache.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Automatic Backups',
|
||||||
|
content: 'The system automatically creates backups at regular intervals (configurable in settings). Old backups are automatically cleaned up based on retention settings.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Restoring Backups',
|
||||||
|
content: 'Click "Restore" on any backup to restore your system to that state. A new backup is automatically created before restoration for safety.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Backup Details',
|
||||||
|
content: 'Click "Details" to view backup metadata including timestamp, version, files included, and their sizes.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Deleting Backups',
|
||||||
|
content: 'Use "Delete" to remove old backups and free up disk space. Deleted backups cannot be recovered.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'diagnostics': {
|
||||||
|
title: 'Network Diagnostics',
|
||||||
|
description: 'Test and troubleshoot network connectivity',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Overview',
|
||||||
|
content: 'Network Diagnostics provides tools to test DNS resolution, network connectivity, and troubleshoot connection issues.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'DNS Lookup',
|
||||||
|
content: 'Test DNS resolution for any domain. Supports multiple record types (A, AAAA, MX, TXT, NS, CNAME, SRV, PTR, SOA).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ping',
|
||||||
|
content: 'Ping a target domain or IP address to test basic connectivity and measure response times.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Traceroute',
|
||||||
|
content: 'Trace the network path to a target, showing each hop along the route. Useful for diagnosing routing issues.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Connection Test',
|
||||||
|
content: 'Test TCP connectivity to a specific domain and port. Verifies if a service is reachable.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Network Interfaces',
|
||||||
|
content: 'View information about network interfaces on the system, including IP addresses and interface details.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open info modal
|
||||||
|
function openInfoModal(tabId) {
|
||||||
|
const modal = document.getElementById('infoModal');
|
||||||
|
const content = infoContent[tabId];
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Info content not available for this tab', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleEl = document.getElementById('info-modal-title');
|
||||||
|
const contentDiv = document.getElementById('info-modal-content');
|
||||||
|
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = content.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentDiv) {
|
||||||
|
contentDiv.innerHTML = `
|
||||||
|
<p class="text-lg text-gray-700 dark:text-gray-300 mb-4">${content.description}</p>
|
||||||
|
<div class="space-y-6">
|
||||||
|
${content.sections.map(section => `
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold mb-2 text-gray-900 dark:text-white">${section.title}</h4>
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">${section.content}</p>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.openInfoModal = openInfoModal;
|
||||||
|
window.infoContent = infoContent;
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Interfaces UI functions
|
||||||
|
function cleanupInterfaces() {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm('Cleanup interfaces?', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/cleanup-interfaces', { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Interfaces cleaned up successfully');
|
||||||
|
if (window.genericFetch) window.genericFetch('interfaces', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to cleanup interfaces:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to cleanup interfaces: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.cleanupInterfaces = cleanupInterfaces;
|
||||||
|
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// Local DNS UI functions
|
||||||
|
function openLocalDnsModal(editIndex = -1) {
|
||||||
|
const modal = document.getElementById('localDnsModal');
|
||||||
|
const title = document.getElementById('local-dns-title');
|
||||||
|
const nameInput = document.getElementById('local-name');
|
||||||
|
const typeSelect = document.getElementById('local-type');
|
||||||
|
const ttlInput = document.getElementById('local-ttl');
|
||||||
|
const submitBtn = document.getElementById('local-submit');
|
||||||
|
if (!modal || !title || !nameInput || !typeSelect || !ttlInput || !submitBtn) return;
|
||||||
|
|
||||||
|
nameInput.value = '';
|
||||||
|
typeSelect.value = 'A';
|
||||||
|
ttlInput.value = 3600;
|
||||||
|
if (window.updateLocalForm) updateLocalForm();
|
||||||
|
if (editIndex >= 0) {
|
||||||
|
const rec = window.localDnsData?.find(r => r.index === editIndex);
|
||||||
|
if (rec) {
|
||||||
|
nameInput.value = rec.name;
|
||||||
|
typeSelect.value = rec.type;
|
||||||
|
if (window.updateLocalForm) updateLocalForm(rec);
|
||||||
|
ttlInput.value = rec.ttl;
|
||||||
|
title.textContent = 'Edit Local DNS Record';
|
||||||
|
submitBtn.textContent = 'Update';
|
||||||
|
window.editLocalIndex = editIndex;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title.textContent = 'Add Local DNS Record';
|
||||||
|
submitBtn.textContent = 'Add';
|
||||||
|
window.editLocalIndex = -1;
|
||||||
|
}
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLocalForm(rec = null) {
|
||||||
|
const typeEl = document.getElementById('local-type');
|
||||||
|
const fields = document.getElementById('local-value-fields');
|
||||||
|
if (!typeEl || !fields) return;
|
||||||
|
const type = typeEl.value;
|
||||||
|
fields.innerHTML = '';
|
||||||
|
let inputHtml = '';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'A':
|
||||||
|
case 'AAAA':
|
||||||
|
inputHtml = `<input id="local-data" placeholder="IP Address" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||||
|
break;
|
||||||
|
case 'CNAME':
|
||||||
|
inputHtml = `<input id="local-data" placeholder="Target Domain" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||||
|
break;
|
||||||
|
case 'TXT':
|
||||||
|
inputHtml = `<input id="local-data" placeholder="Text" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||||
|
break;
|
||||||
|
case 'MX':
|
||||||
|
inputHtml = `<input id="local-preference" type="number" placeholder="Preference" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-exchange" placeholder="Exchange" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||||
|
break;
|
||||||
|
case 'SRV':
|
||||||
|
inputHtml = `<input id="local-priority" type="number" placeholder="Priority" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-weight" type="number" placeholder="Weight" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-port" type="number" placeholder="Port" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-target" placeholder="Target" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||||
|
break;
|
||||||
|
case 'SOA':
|
||||||
|
inputHtml = `<input id="local-mname" placeholder="Primary Name Server" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-rname" placeholder="Responsible Person" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-serial" type="number" placeholder="Serial" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-refresh" type="number" placeholder="Refresh" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-retry" type="number" placeholder="Retry" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-expire" type="number" placeholder="Expire" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-minimum" type="number" placeholder="Minimum TTL" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||||
|
break;
|
||||||
|
case 'CAA':
|
||||||
|
inputHtml = `<input id="local-flags" type="number" placeholder="Flags" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-tag" placeholder="Tag (e.g., issue, issuewild, iodef)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-value" placeholder="Value" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||||
|
break;
|
||||||
|
case 'NS':
|
||||||
|
case 'PTR':
|
||||||
|
inputHtml = `<input id="local-data" placeholder="Name Server" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
inputHtml = `<input id="local-data" placeholder="Record Data" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fields.innerHTML = inputHtml;
|
||||||
|
|
||||||
|
if (rec) {
|
||||||
|
switch (type) {
|
||||||
|
case 'MX':
|
||||||
|
const prefEl = document.getElementById('local-preference');
|
||||||
|
const exchEl = document.getElementById('local-exchange');
|
||||||
|
if (prefEl) prefEl.value = rec.preference || '';
|
||||||
|
if (exchEl) exchEl.value = rec.exchange || '';
|
||||||
|
break;
|
||||||
|
case 'SRV':
|
||||||
|
const priEl = document.getElementById('local-priority');
|
||||||
|
const weightEl = document.getElementById('local-weight');
|
||||||
|
const portEl = document.getElementById('local-port');
|
||||||
|
const targetEl = document.getElementById('local-target');
|
||||||
|
if (priEl) priEl.value = rec.priority || '';
|
||||||
|
if (weightEl) weightEl.value = rec.weight || '';
|
||||||
|
if (portEl) portEl.value = rec.port || '';
|
||||||
|
if (targetEl) targetEl.value = rec.target || '';
|
||||||
|
break;
|
||||||
|
case 'SOA':
|
||||||
|
const mnameEl = document.getElementById('local-mname');
|
||||||
|
const rnameEl = document.getElementById('local-rname');
|
||||||
|
const serialEl = document.getElementById('local-serial');
|
||||||
|
const refreshEl = document.getElementById('local-refresh');
|
||||||
|
const retryEl = document.getElementById('local-retry');
|
||||||
|
const expireEl = document.getElementById('local-expire');
|
||||||
|
const minimumEl = document.getElementById('local-minimum');
|
||||||
|
if (mnameEl) mnameEl.value = rec.mname || '';
|
||||||
|
if (rnameEl) rnameEl.value = rec.rname || '';
|
||||||
|
if (serialEl) serialEl.value = rec.serial || '';
|
||||||
|
if (refreshEl) refreshEl.value = rec.refresh || '';
|
||||||
|
if (retryEl) retryEl.value = rec.retry || '';
|
||||||
|
if (expireEl) expireEl.value = rec.expire || '';
|
||||||
|
if (minimumEl) minimumEl.value = rec.minimum || '';
|
||||||
|
break;
|
||||||
|
case 'CAA':
|
||||||
|
const flagsEl = document.getElementById('local-flags');
|
||||||
|
const tagEl = document.getElementById('local-tag');
|
||||||
|
const valueEl = document.getElementById('local-value');
|
||||||
|
if (flagsEl) flagsEl.value = rec.flags || '';
|
||||||
|
if (tagEl) tagEl.value = rec.tag || '';
|
||||||
|
if (valueEl) valueEl.value = rec.value || '';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
const dataEl = document.getElementById('local-data');
|
||||||
|
if (dataEl) dataEl.value = rec.data || rec.value || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLocalDns() {
|
||||||
|
const nameEl = document.getElementById('local-name');
|
||||||
|
const typeEl = document.getElementById('local-type');
|
||||||
|
const ttlEl = document.getElementById('local-ttl');
|
||||||
|
if (!nameEl || !typeEl || !ttlEl) return;
|
||||||
|
|
||||||
|
const name = nameEl.value;
|
||||||
|
const type = typeEl.value;
|
||||||
|
const ttl = parseInt(ttlEl.value) || 3600;
|
||||||
|
let record = { name, type, ttl, class: 'IN' };
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'MX':
|
||||||
|
const prefEl = document.getElementById('local-preference');
|
||||||
|
const exchEl = document.getElementById('local-exchange');
|
||||||
|
record.preference = parseInt(prefEl?.value) || 10;
|
||||||
|
record.exchange = exchEl?.value || '';
|
||||||
|
break;
|
||||||
|
case 'SRV':
|
||||||
|
record.priority = parseInt(document.getElementById('local-priority')?.value) || 0;
|
||||||
|
record.weight = parseInt(document.getElementById('local-weight')?.value) || 0;
|
||||||
|
record.port = parseInt(document.getElementById('local-port')?.value) || 0;
|
||||||
|
record.target = document.getElementById('local-target')?.value || '';
|
||||||
|
break;
|
||||||
|
case 'SOA':
|
||||||
|
record.mname = document.getElementById('local-mname')?.value || '';
|
||||||
|
record.rname = document.getElementById('local-rname')?.value || '';
|
||||||
|
record.serial = parseInt(document.getElementById('local-serial')?.value) || 0;
|
||||||
|
record.refresh = parseInt(document.getElementById('local-refresh')?.value) || 0;
|
||||||
|
record.retry = parseInt(document.getElementById('local-retry')?.value) || 0;
|
||||||
|
record.expire = parseInt(document.getElementById('local-expire')?.value) || 0;
|
||||||
|
record.minimum = parseInt(document.getElementById('local-minimum')?.value) || 0;
|
||||||
|
break;
|
||||||
|
case 'CAA':
|
||||||
|
record.flags = parseInt(document.getElementById('local-flags')?.value) || 0;
|
||||||
|
record.tag = document.getElementById('local-tag')?.value || '';
|
||||||
|
record.value = document.getElementById('local-value')?.value || '';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
record.data = document.getElementById('local-data')?.value || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = window.editLocalIndex >= 0;
|
||||||
|
const url = isEdit ? '/api/update-local-dns' : '/api/add-local-dns';
|
||||||
|
const body = isEdit ? JSON.stringify({ index: window.editLocalIndex, record }) : JSON.stringify(record);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification(isEdit ? 'Record updated successfully' : 'Record added successfully');
|
||||||
|
const modal = document.getElementById('localDnsModal');
|
||||||
|
if (modal) modal.close();
|
||||||
|
if (window.genericFetch) window.genericFetch('local-dns', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to submit local DNS record:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to submit record: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editLocalDns(index) {
|
||||||
|
openLocalDnsModal(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteLocalDns(index) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm('Delete this record?', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/delete-local-dns', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ index })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Record deleted successfully');
|
||||||
|
if (window.genericFetch) window.genericFetch('local-dns', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete local DNS record:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to delete record: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleVersionPreference(domain, isPublic) {
|
||||||
|
try {
|
||||||
|
const version = isPublic ? 'public' : 'p2p';
|
||||||
|
const response = await fetch('/api/update-version-preference', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ domain, version })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification(`Version preference for ${domain} set to ${version}`);
|
||||||
|
if (window.genericFetch) window.genericFetch('dns-conflicts', true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update version preference:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to update version preference: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openLocalDnsModal = openLocalDnsModal;
|
||||||
|
window.updateLocalForm = updateLocalForm;
|
||||||
|
window.submitLocalDns = submitLocalDns;
|
||||||
|
window.editLocalDns = editLocalDns;
|
||||||
|
window.deleteLocalDns = deleteLocalDns;
|
||||||
|
window.toggleVersionPreference = toggleVersionPreference;
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Logs UI functions - terminal rendering
|
||||||
|
function renderLogs() {
|
||||||
|
if (!window.terminalInitialized) {
|
||||||
|
// Check if Terminal is available (from xterm.js CDN)
|
||||||
|
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
|
||||||
|
console.error('Terminal or FitAddon not loaded. Make sure xterm.js scripts are loaded.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.term = new Terminal();
|
||||||
|
window.fitAddon = new FitAddon.FitAddon();
|
||||||
|
window.term.loadAddon(window.fitAddon);
|
||||||
|
const terminalEl = document.getElementById('terminal');
|
||||||
|
if (terminalEl) {
|
||||||
|
window.term.open(terminalEl);
|
||||||
|
window.terminalInitialized = true;
|
||||||
|
} else {
|
||||||
|
console.error('Terminal element not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (window.term) {
|
||||||
|
window.term.reset();
|
||||||
|
if (window.logBuffer && window.logBuffer.length > 0) {
|
||||||
|
window.logBuffer.forEach(line => window.term.writeln(line));
|
||||||
|
}
|
||||||
|
if (window.fitAddon) {
|
||||||
|
window.fitAddon.fit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle window resize for terminal
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (window.activeTab === 'logs' && window.fitAddon) {
|
||||||
|
window.fitAddon.fit();
|
||||||
|
}
|
||||||
|
const holesailLogModal = document.getElementById('holesailLogModal');
|
||||||
|
if (holesailLogModal && holesailLogModal.open && window.holesailFitAddon) {
|
||||||
|
window.holesailFitAddon.fit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.renderLogs = renderLogs;
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Notification and confirmation dialog functions
|
||||||
|
function showNotification(message, type = 'success') {
|
||||||
|
const container = document.getElementById('notifications');
|
||||||
|
if (!container) return;
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
let bgColor = 'bg-green-500';
|
||||||
|
if (type === 'error') bgColor = 'bg-red-500';
|
||||||
|
else if (type === 'warning') bgColor = 'bg-yellow-500';
|
||||||
|
else if (type === 'info') bgColor = 'bg-blue-500';
|
||||||
|
|
||||||
|
notification.classList.add(
|
||||||
|
'p-4', 'rounded-lg', 'shadow-lg', 'text-white',
|
||||||
|
bgColor,
|
||||||
|
'transition-all', 'duration-300', 'opacity-0', 'transform', 'translate-y-4'
|
||||||
|
);
|
||||||
|
notification.textContent = message;
|
||||||
|
container.appendChild(notification);
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('opacity-0', 'translate-y-4');
|
||||||
|
notification.classList.add('opacity-100', 'translate-y-0');
|
||||||
|
}, 10);
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('opacity-100', 'translate-y-0');
|
||||||
|
notification.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// showConfirm is now provided by confirmation-modal.js
|
||||||
|
// This function is kept for backward compatibility
|
||||||
|
function showConfirm(message, callback, options = {}) {
|
||||||
|
if (window.ConfirmationModal) {
|
||||||
|
return window.ConfirmationModal.show({
|
||||||
|
...options,
|
||||||
|
message,
|
||||||
|
onConfirm: callback
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Fallback to old behavior if modal not loaded
|
||||||
|
const messageEl = document.getElementById('confirm-message');
|
||||||
|
const yesBtn = document.getElementById('confirm-yes');
|
||||||
|
const noBtn = document.getElementById('confirm-no');
|
||||||
|
const modal = document.getElementById('confirmModal');
|
||||||
|
if (!messageEl || !yesBtn || !noBtn || !modal) return;
|
||||||
|
|
||||||
|
messageEl.textContent = message;
|
||||||
|
modal.showModal();
|
||||||
|
|
||||||
|
const yesHandler = () => {
|
||||||
|
callback();
|
||||||
|
modal.close();
|
||||||
|
yesBtn.removeEventListener('click', yesHandler);
|
||||||
|
noBtn.removeEventListener('click', noHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
const noHandler = () => {
|
||||||
|
modal.close();
|
||||||
|
yesBtn.removeEventListener('click', yesHandler);
|
||||||
|
noBtn.removeEventListener('click', noHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
yesBtn.addEventListener('click', yesHandler);
|
||||||
|
noBtn.addEventListener('click', noHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showNotification = showNotification;
|
||||||
|
window.showConfirm = showConfirm;
|
||||||
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Enhanced Peers UI functions
|
||||||
|
|
||||||
|
let peerChart = null;
|
||||||
|
|
||||||
|
// Render peers list - uses generic pagination system
|
||||||
|
async function renderPeers() {
|
||||||
|
if (window.genericFetch) {
|
||||||
|
await window.genericFetch('peers', true);
|
||||||
|
}
|
||||||
|
renderPeerGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter peers - uses generic filter system
|
||||||
|
function filterPeers() {
|
||||||
|
if (window.genericFilter) {
|
||||||
|
window.genericFilter('peers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show peer details modal
|
||||||
|
async function showPeerDetails(peerId) {
|
||||||
|
try {
|
||||||
|
const [peerRes, historyRes] = await Promise.all([
|
||||||
|
fetch(`/api/peers/${encodeURIComponent(peerId)}`),
|
||||||
|
fetch(`/api/peers/${encodeURIComponent(peerId)}/history`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!peerRes.ok || !historyRes.ok) {
|
||||||
|
throw new Error('Failed to fetch peer details');
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer = await peerRes.json();
|
||||||
|
const history = await historyRes.json();
|
||||||
|
|
||||||
|
const modal = document.getElementById('peerDetailsModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const content = document.getElementById('peer-details-content');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const uptime = peer.uptime ? (window.formatUptime ? window.formatUptime(peer.uptime) : `${Math.floor(peer.uptime / 1000)}s`) : 'N/A';
|
||||||
|
const connectTime = peer.connectTime ? new Date(peer.connectTime).toLocaleString() : 'N/A';
|
||||||
|
const lastSeen = peer.metrics?.lastSeen ? new Date(peer.metrics.lastSeen).toLocaleString() : 'N/A';
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Peer Information</h4>
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded">
|
||||||
|
<p><strong>ID:</strong> <span class="font-mono text-sm break-all">${peer.id}</span></p>
|
||||||
|
<p><strong>Status:</strong> ${peer.connected ? '<span class="text-green-600">Connected</span>' : '<span class="text-gray-600">Disconnected</span>'}</p>
|
||||||
|
<p><strong>Uptime:</strong> ${uptime}</p>
|
||||||
|
<p><strong>Connected At:</strong> ${connectTime}</p>
|
||||||
|
<p><strong>Blocked:</strong> ${peer.isBlocked ? '<span class="text-red-600">Yes</span>' : '<span class="text-green-600">No</span>'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Metrics</h4>
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded">
|
||||||
|
<p><strong>Total Connections:</strong> ${peer.metrics?.connections || 0}</p>
|
||||||
|
<p><strong>Total Duration:</strong> ${peer.metrics?.totalDuration ? (window.formatDuration ? window.formatDuration(peer.metrics.totalDuration) : `${Math.floor(peer.metrics.totalDuration / 1000)}s`) : '0s'}</p>
|
||||||
|
<p><strong>Average Duration:</strong> ${peer.metrics?.avgDuration ? (window.formatDuration ? window.formatDuration(peer.metrics.avgDuration) : `${Math.floor(peer.metrics.avgDuration / 1000)}s`) : 'N/A'}</p>
|
||||||
|
<p><strong>Last Seen:</strong> ${lastSeen}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Connection History (Last 50)</h4>
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded max-h-64 overflow-y-auto">
|
||||||
|
${history.length === 0
|
||||||
|
? '<p class="text-gray-500">No history available</p>'
|
||||||
|
: history.slice(-50).reverse().map(event => `
|
||||||
|
<div class="mb-2 pb-2 border-b border-gray-300 dark:border-gray-600">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="font-semibold ${event.type === 'connect' ? 'text-green-600' : 'text-red-600'}">${event.type === 'connect' ? 'Connected' : 'Disconnected'}</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">${new Date(event.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
${event.duration ? `<div class="text-sm text-gray-600 dark:text-gray-400">Duration: ${window.formatDuration ? window.formatDuration(event.duration) : `${Math.floor(event.duration / 1000)}s`}</div>` : ''}
|
||||||
|
${event.error ? `<div class="text-sm text-red-600">Error: ${event.error}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.showModal();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch peer details:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to load peer details: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block peer
|
||||||
|
async function blockPeer(peerId) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Block peer ${peerId.substring(0, 16)}...?`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/peers/${encodeURIComponent(peerId)}/block`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to block peer');
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Peer blocked successfully');
|
||||||
|
await renderPeers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to block peer:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to block peer: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock peer
|
||||||
|
async function unblockPeer(peerId) {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm(`Unblock peer ${peerId.substring(0, 16)}...?`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/peers/${encodeURIComponent(peerId)}/unblock`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to unblock peer');
|
||||||
|
}
|
||||||
|
if (window.showNotification) window.showNotification('Peer unblocked successfully');
|
||||||
|
await renderPeers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to unblock peer:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to unblock peer: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render peer connection graph
|
||||||
|
function renderPeerGraph() {
|
||||||
|
const canvas = document.getElementById('peer-graph-chart');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (peerChart) {
|
||||||
|
peerChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get peers data from global state
|
||||||
|
const peersData = window.peersData || [];
|
||||||
|
|
||||||
|
// Group peers by connection status over time (simplified - using current data)
|
||||||
|
const connected = peersData.filter(p => p.connected).length;
|
||||||
|
const disconnected = peersData.filter(p => !p.connected).length;
|
||||||
|
const blocked = peersData.filter(p => p.isBlocked).length;
|
||||||
|
|
||||||
|
peerChart = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['Connected', 'Disconnected', 'Blocked'],
|
||||||
|
datasets: [{
|
||||||
|
data: [connected, disconnected, blocked],
|
||||||
|
backgroundColor: [
|
||||||
|
'rgb(34, 197, 94)',
|
||||||
|
'rgb(107, 114, 128)',
|
||||||
|
'rgb(239, 68, 68)'
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
color: '#f1f5f9' // White text for better readability on dark background
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.renderPeers = renderPeers;
|
||||||
|
window.showPeerDetails = showPeerDetails;
|
||||||
|
window.blockPeer = blockPeer;
|
||||||
|
window.unblockPeer = unblockPeer;
|
||||||
|
window.filterPeers = filterPeers;
|
||||||
|
|
||||||
@@ -0,0 +1,838 @@
|
|||||||
|
// Plugins UI functions
|
||||||
|
|
||||||
|
let pluginsData = [];
|
||||||
|
|
||||||
|
// Fetch plugins from API
|
||||||
|
async function fetchPlugins() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/plugins');
|
||||||
|
const data = await res.json();
|
||||||
|
pluginsData = data.plugins || [];
|
||||||
|
return pluginsData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch plugins:', err);
|
||||||
|
if (window.showNotification) window.showNotification('Failed to load plugins', 'error');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize plugin log buffers and terminals (if not already initialized in state.js)
|
||||||
|
if (!window.pluginLogBuffers) {
|
||||||
|
window.pluginLogBuffers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.pluginTerminals) {
|
||||||
|
window.pluginTerminals = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.pluginFitAddons) {
|
||||||
|
window.pluginFitAddons = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.pluginResizeObservers) {
|
||||||
|
window.pluginResizeObservers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render plugins
|
||||||
|
async function renderPlugins() {
|
||||||
|
const container = document.getElementById('pluginsContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const plugins = await fetchPlugins();
|
||||||
|
pluginsData = plugins;
|
||||||
|
|
||||||
|
if (plugins.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="theme-card p-8 text-center">
|
||||||
|
<p class="theme-text-tertiary text-lg">No plugins found</p>
|
||||||
|
<p class="theme-text-tertiary text-sm mt-2">Plugins are loaded from the plugin-sites/ directory</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort plugins: example.plugin always at the bottom
|
||||||
|
const sortedPlugins = [...plugins].sort((a, b) => {
|
||||||
|
if (a.domain === 'example.plugin') return 1;
|
||||||
|
if (b.domain === 'example.plugin') return -1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update pluginsData to sorted order for filtering
|
||||||
|
pluginsData = sortedPlugins;
|
||||||
|
|
||||||
|
container.innerHTML = sortedPlugins.map(plugin => renderPluginCard(plugin)).join('');
|
||||||
|
|
||||||
|
// Don't initialize terminals on page load - they'll be initialized when logs section is shown
|
||||||
|
|
||||||
|
// Attach event listeners for action buttons
|
||||||
|
sortedPlugins.forEach(plugin => {
|
||||||
|
plugin.actions.forEach(action => {
|
||||||
|
const buttonId = `action-${plugin.domain}-${action.name}`;
|
||||||
|
const button = document.getElementById(buttonId);
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', () => executeAction(plugin.domain, action.name, action));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize terminal for a plugin
|
||||||
|
function initializePluginTerminal(domain) {
|
||||||
|
const terminalEl = document.getElementById(`plugin-terminal-${domain}`);
|
||||||
|
if (!terminalEl) {
|
||||||
|
// Terminal element doesn't exist yet, try again after a short delay
|
||||||
|
setTimeout(() => initializePluginTerminal(domain), 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up existing terminal if any
|
||||||
|
cleanupPluginTerminal(domain);
|
||||||
|
|
||||||
|
// Check if Terminal is available
|
||||||
|
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
|
||||||
|
terminalEl.innerHTML = '<p class="theme-text-tertiary p-2">Terminal not available. Please refresh the page.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const term = new Terminal({
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace',
|
||||||
|
theme: {
|
||||||
|
background: '#000000',
|
||||||
|
foreground: '#ffffff'
|
||||||
|
},
|
||||||
|
rows: 10,
|
||||||
|
cols: 80
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
|
||||||
|
term.open(terminalEl);
|
||||||
|
|
||||||
|
// Small delay to ensure DOM is ready before fitting
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
fitAddon.fit();
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore fit errors
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Store terminal first so logs can be written to it immediately
|
||||||
|
if (!window.pluginTerminals) {
|
||||||
|
window.pluginTerminals = new Map();
|
||||||
|
}
|
||||||
|
window.pluginTerminals.set(domain, term);
|
||||||
|
|
||||||
|
// Load existing log buffer if available
|
||||||
|
if (!window.pluginLogBuffers) {
|
||||||
|
window.pluginLogBuffers = new Map();
|
||||||
|
}
|
||||||
|
const buffer = window.pluginLogBuffers.get(domain) || [];
|
||||||
|
if (buffer.length > 0) {
|
||||||
|
buffer.forEach(line => term.writeln(line));
|
||||||
|
} else {
|
||||||
|
term.writeln('No logs yet. Logs will appear here as they are generated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store fitAddon for resize handling
|
||||||
|
if (!window.pluginFitAddons) {
|
||||||
|
window.pluginFitAddons = new Map();
|
||||||
|
}
|
||||||
|
window.pluginFitAddons.set(domain, fitAddon);
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
try {
|
||||||
|
if (window.pluginFitAddons && window.pluginFitAddons.has(domain)) {
|
||||||
|
const addon = window.pluginFitAddons.get(domain);
|
||||||
|
if (addon) {
|
||||||
|
addon.fit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore resize errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(terminalEl);
|
||||||
|
|
||||||
|
// Store observer for cleanup
|
||||||
|
if (!window.pluginResizeObservers) {
|
||||||
|
window.pluginResizeObservers = new Map();
|
||||||
|
}
|
||||||
|
window.pluginResizeObservers.set(domain, resizeObserver);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error initializing terminal for plugin ${domain}:`, err);
|
||||||
|
terminalEl.innerHTML = `<p class="text-red-400 p-2">Error initializing terminal: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a single plugin card
|
||||||
|
function renderPluginCard(plugin) {
|
||||||
|
const actionsHtml = plugin.status === 'stopped'
|
||||||
|
? '<p class="text-sm theme-text-tertiary mt-4">Actions unavailable (plugin stopped)</p>'
|
||||||
|
: plugin.actions.length > 0
|
||||||
|
? `
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4 class="text-sm font-semibold theme-text-primary mb-2">Actions</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
${plugin.actions.map(action => `
|
||||||
|
<button
|
||||||
|
id="action-${plugin.domain}-${action.name}"
|
||||||
|
class="px-3 py-1 text-sm rounded transition-colors"
|
||||||
|
style="background: rgba(59, 130, 246, 0.3); border: 1px solid rgba(59, 130, 246, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
||||||
|
onmouseover="this.style.background='rgba(59, 130, 246, 0.5)'; this.style.borderColor='rgba(59, 130, 246, 0.7)'"
|
||||||
|
onmouseout="this.style.background='rgba(59, 130, 246, 0.3)'; this.style.borderColor='rgba(59, 130, 246, 0.5)'"
|
||||||
|
title="${action.description || action.label}"
|
||||||
|
>
|
||||||
|
${action.icon || '⚡'} ${action.label || action.name}
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: '<p class="text-sm theme-text-tertiary mt-4">No actions registered</p>';
|
||||||
|
|
||||||
|
const settingsHtml = plugin.status === 'stopped'
|
||||||
|
? '<p class="text-sm theme-text-tertiary mt-4">Settings unavailable (plugin stopped)</p>'
|
||||||
|
: Object.keys(plugin.settings).length > 0
|
||||||
|
? `
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4 class="text-sm font-semibold theme-text-primary mb-2">Settings</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
${Object.entries(plugin.settings).map(([key, setting]) => renderSettingInput(plugin.domain, key, setting)).join('')}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick="savePluginSettings('${plugin.domain}')"
|
||||||
|
class="mt-3 px-4 py-2 text-sm rounded transition-colors"
|
||||||
|
style="background: rgba(34, 197, 94, 0.3); border: 1px solid rgba(34, 197, 94, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
||||||
|
onmouseover="this.style.background='rgba(34, 197, 94, 0.5)'; this.style.borderColor='rgba(34, 197, 94, 0.7)'"
|
||||||
|
onmouseout="this.style.background='rgba(34, 197, 94, 0.3)'; this.style.borderColor='rgba(34, 197, 94, 0.5)'"
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: '<p class="text-sm theme-text-tertiary mt-4">No settings registered</p>';
|
||||||
|
|
||||||
|
const statusBadge = plugin.status === 'loaded'
|
||||||
|
? '<span class="px-2 py-1 bg-green-500 rounded text-xs" style="color: var(--text-primary);">Loaded</span>'
|
||||||
|
: plugin.status === 'stopped'
|
||||||
|
? '<span class="px-2 py-1 bg-red-500 rounded text-xs" style="color: var(--text-primary);">Stopped</span>'
|
||||||
|
: '<span class="px-2 py-1 bg-primary rounded text-xs" style="color: var(--text-primary);">Static</span>';
|
||||||
|
|
||||||
|
const featuresHtml = [
|
||||||
|
plugin.hasHandler ? '<span class="text-xs bg-blue-500 px-2 py-1 rounded" style="color: var(--text-primary);">Handler</span>' : '',
|
||||||
|
plugin.hasWww ? '<span class="text-xs bg-purple-500 px-2 py-1 rounded" style="color: var(--text-primary);">Web UI</span>' : '',
|
||||||
|
plugin.hasDatabase ? '<span class="text-xs bg-orange-500 px-2 py-1 rounded" style="color: var(--text-primary);">Database</span>' : ''
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="theme-card p-6 plugin-card" data-plugin-domain="${plugin.domain}">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<h3 class="text-xl font-bold theme-text-primary flex items-center gap-2">
|
||||||
|
${plugin.icon ? `<i class="fa-solid fa-${escapeHtml(plugin.icon)}"></i>` : ''}
|
||||||
|
${escapeHtml(plugin.name)}
|
||||||
|
</h3>
|
||||||
|
<div class="ml-2">
|
||||||
|
${statusBadge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm theme-text-secondary">${escapeHtml(plugin.description || 'No description')}</p>
|
||||||
|
<div class="flex items-center gap-4 mt-2 text-xs theme-text-tertiary">
|
||||||
|
<span>v${escapeHtml(plugin.version)}</span>
|
||||||
|
${plugin.author ? `<span>by ${escapeHtml(plugin.author)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${featuresHtml ? `<div class="flex gap-2 mt-2">${featuresHtml}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 items-end">
|
||||||
|
<div class="flex items-center gap-2 theme-glass px-3 py-2 rounded-lg">
|
||||||
|
<span class="text-xs font-semibold theme-text-secondary uppercase tracking-wide">Status</span>
|
||||||
|
${(['p2ns.admin', 'global.profile'].includes(plugin.domain) ? `
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-11 h-6 rounded-full flex items-center justify-end px-1" style="background: rgba(59, 130, 246, 0.3); border: 1px solid rgba(59, 130, 246, 0.5); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1);">
|
||||||
|
<div class="w-5 h-5 rounded-full" style="background: var(--text-primary); border: 1px solid var(--border-color);"></div>
|
||||||
|
</div>
|
||||||
|
<span class="ml-3 text-sm font-medium theme-text-primary min-w-[70px]">
|
||||||
|
<span style="color: var(--success);">Enabled</span>
|
||||||
|
<span class="ml-2 text-xs theme-text-tertiary">(System)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only peer"
|
||||||
|
${plugin.enabled !== false ? 'checked' : ''}
|
||||||
|
onchange="togglePluginEnabled('${plugin.domain}', this.checked)"
|
||||||
|
id="toggle-${plugin.domain}"
|
||||||
|
>
|
||||||
|
<div class="w-11 h-6 rounded-full peer peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:rounded-full after:h-5 after:w-5 after:transition-all plugin-toggle-switch" style="background: var(--bg-glass); border: 1px solid var(--border-color); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"></div>
|
||||||
|
<span class="ml-3 text-sm font-medium theme-text-primary min-w-[70px]">
|
||||||
|
${plugin.enabled !== false ? '<span style="color: var(--success);">Enabled</span>' : '<span style="color: var(--error);">Disabled</span>'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
${plugin.status === 'loaded' ? `
|
||||||
|
<button
|
||||||
|
onclick="reloadPlugin('${plugin.domain}')"
|
||||||
|
class="px-4 py-2 rounded transition-colors flex items-center gap-2"
|
||||||
|
style="background: rgba(234, 179, 8, 0.3); border: 1px solid rgba(234, 179, 8, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
||||||
|
onmouseover="this.style.background='rgba(234, 179, 8, 0.5)'; this.style.borderColor='rgba(234, 179, 8, 0.7)'"
|
||||||
|
onmouseout="this.style.background='rgba(234, 179, 8, 0.3)'; this.style.borderColor='rgba(234, 179, 8, 0.5)'"
|
||||||
|
title="Reload this plugin without restarting P2NS"
|
||||||
|
>
|
||||||
|
🔄 Restart
|
||||||
|
</button>
|
||||||
|
${(['p2ns.admin', 'global.profile'].includes(plugin.domain) ? '' : `
|
||||||
|
<button
|
||||||
|
onclick="stopPlugin('${plugin.domain}')"
|
||||||
|
class="px-4 py-2 theme-button-info rounded theme-glass-hover transition-colors flex items-center gap-2"
|
||||||
|
title="Stop this plugin (unload it from memory)"
|
||||||
|
>
|
||||||
|
⏹️ Stop
|
||||||
|
</button>
|
||||||
|
`)}
|
||||||
|
` : plugin.enabled !== false ? `
|
||||||
|
<button
|
||||||
|
onclick="startPlugin('${plugin.domain}')"
|
||||||
|
class="px-4 py-2 rounded transition-colors flex items-center gap-2"
|
||||||
|
style="background: rgba(34, 197, 94, 0.3); border: 1px solid rgba(34, 197, 94, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
||||||
|
onmouseover="this.style.background='rgba(34, 197, 94, 0.5)'; this.style.borderColor='rgba(34, 197, 94, 0.7)'"
|
||||||
|
onmouseout="this.style.background='rgba(34, 197, 94, 0.3)'; this.style.borderColor='rgba(34, 197, 94, 0.5)'"
|
||||||
|
title="Start this plugin (load it into memory)"
|
||||||
|
>
|
||||||
|
▶️ Start
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t pt-4 mt-4" style="border-color: var(--border-color);">
|
||||||
|
${actionsHtml}
|
||||||
|
${settingsHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${plugin.status === 'stopped' ? `
|
||||||
|
<div class="mt-4 p-3 rounded theme-glass" style="background: rgba(234, 179, 8, 0.2); border: 1px solid rgba(234, 179, 8, 0.4); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);">
|
||||||
|
<p class="text-sm" style="color: var(--text-primary);">
|
||||||
|
⚠️ This plugin is currently stopped. Actions and settings are not available until it is started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div id="plugin-logs-${plugin.domain}" class="mt-4 hidden">
|
||||||
|
<h4 class="text-sm font-semibold theme-text-primary mb-2">Logs</h4>
|
||||||
|
<div id="plugin-terminal-${plugin.domain}" class="bg-black rounded-lg overflow-hidden" style="height: 200px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-xs theme-text-tertiary">
|
||||||
|
<span>Domain: <code class="theme-glass px-1 rounded">${escapeHtml(plugin.domain)}</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a setting input field
|
||||||
|
function renderSettingInput(domain, key, setting) {
|
||||||
|
const inputId = `setting-${domain}-${key}`;
|
||||||
|
// Use saved value if available, otherwise use default
|
||||||
|
const currentValue = setting.value !== undefined ? setting.value : (setting.default !== undefined ? setting.default : '');
|
||||||
|
|
||||||
|
switch (setting.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return `
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="${inputId}"
|
||||||
|
data-plugin-domain="${domain}"
|
||||||
|
data-setting-key="${key}"
|
||||||
|
${currentValue ? 'checked' : ''}
|
||||||
|
class="w-4 h-4 text-primary theme-glass rounded focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<label for="${inputId}" class="text-sm theme-text-primary">
|
||||||
|
${escapeHtml(setting.label || key)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
${setting.description ? `<p class="text-xs theme-text-tertiary ml-6">${escapeHtml(setting.description)}</p>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
|
||||||
|
${escapeHtml(setting.label || key)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="${inputId}"
|
||||||
|
data-plugin-domain="${domain}"
|
||||||
|
data-setting-key="${key}"
|
||||||
|
value="${currentValue}"
|
||||||
|
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
const optionsHtml = (setting.options || []).map(opt => {
|
||||||
|
const value = typeof opt === 'object' ? opt.value : opt;
|
||||||
|
const label = typeof opt === 'object' ? opt.label : opt;
|
||||||
|
return `<option value="${escapeHtml(value)}" ${value === currentValue ? 'selected' : ''}>${escapeHtml(label)}</option>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
|
||||||
|
${escapeHtml(setting.label || key)}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="${inputId}"
|
||||||
|
data-plugin-domain="${domain}"
|
||||||
|
data-setting-key="${key}"
|
||||||
|
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
${optionsHtml}
|
||||||
|
</select>
|
||||||
|
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
|
||||||
|
${escapeHtml(setting.label || key)}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="${inputId}"
|
||||||
|
data-plugin-domain="${domain}"
|
||||||
|
data-setting-key="${key}"
|
||||||
|
rows="3"
|
||||||
|
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>${escapeHtml(currentValue)}</textarea>
|
||||||
|
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
default: // string
|
||||||
|
return `
|
||||||
|
<div>
|
||||||
|
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
|
||||||
|
${escapeHtml(setting.label || key)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="${inputId}"
|
||||||
|
data-plugin-domain="${domain}"
|
||||||
|
data-setting-key="${key}"
|
||||||
|
value="${escapeHtml(currentValue)}"
|
||||||
|
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute a plugin action
|
||||||
|
async function executeAction(domain, actionName, action) {
|
||||||
|
if (!action) {
|
||||||
|
const plugin = pluginsData.find(p => p.domain === domain);
|
||||||
|
action = plugin?.actions?.find(a => a.name === actionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
if (window.showNotification) window.showNotification('Action not found', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buttonId = `action-${domain}-${actionName}`;
|
||||||
|
const button = document.getElementById(buttonId);
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = '⏳ Executing...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect parameters if any
|
||||||
|
const params = {};
|
||||||
|
if (action.params && action.params.length > 0) {
|
||||||
|
// TODO: Show modal to collect parameters
|
||||||
|
// For now, execute with empty params
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/plugins/${domain}/actions/${actionName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Action "${action.label || actionName}" executed successfully`, 'success');
|
||||||
|
}
|
||||||
|
// Refresh plugins to get updated state
|
||||||
|
await renderPlugins();
|
||||||
|
} else {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(data.error || 'Action execution failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = `${action.icon || '⚡'} ${action.label || actionName}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error executing action:', err);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to execute action', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonId = `action-${domain}-${actionName}`;
|
||||||
|
const button = document.getElementById(buttonId);
|
||||||
|
if (button && action) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = `${action.icon || '⚡'} ${action.label || actionName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show logs section for a plugin
|
||||||
|
function showPluginLogs(domain) {
|
||||||
|
const logsSection = document.getElementById(`plugin-logs-${domain}`);
|
||||||
|
if (logsSection) {
|
||||||
|
logsSection.classList.remove('hidden');
|
||||||
|
// Always re-initialize terminal to ensure it's set up correctly
|
||||||
|
// Use a small delay to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
initializePluginTerminal(domain);
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop a plugin
|
||||||
|
async function stopPlugin(domain) {
|
||||||
|
// Prevent stopping system plugins
|
||||||
|
const SYSTEM_PLUGINS = ['p2ns.admin', 'global.profile'];
|
||||||
|
if (SYSTEM_PLUGINS.includes(domain)) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Cannot stop system plugin: ${domain}. This plugin is required by the system.`, 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await window.ConfirmationModal.warning(
|
||||||
|
`Stop plugin "${domain}"? This will unload the plugin from memory. You can start it again later.`,
|
||||||
|
{
|
||||||
|
title: 'Stop Plugin',
|
||||||
|
confirmText: 'Stop',
|
||||||
|
cancelText: 'Cancel'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show logs section before cleanup
|
||||||
|
showPluginLogs(domain);
|
||||||
|
|
||||||
|
// Clean up terminal for this plugin
|
||||||
|
cleanupPluginTerminal(domain);
|
||||||
|
|
||||||
|
// Re-initialize after cleanup
|
||||||
|
setTimeout(() => {
|
||||||
|
showPluginLogs(domain);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/plugins/${domain}/stop`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Plugin "${domain}" stopped successfully`, 'success');
|
||||||
|
}
|
||||||
|
// Refresh plugins list
|
||||||
|
await renderPlugins();
|
||||||
|
// Re-show logs section after refresh (with longer delay to ensure DOM is ready)
|
||||||
|
setTimeout(() => {
|
||||||
|
showPluginLogs(domain);
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(data.error || 'Failed to stop plugin', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error stopping plugin:', err);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to stop plugin', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up terminal for a plugin
|
||||||
|
function cleanupPluginTerminal(domain) {
|
||||||
|
try {
|
||||||
|
// Dispose terminal
|
||||||
|
if (window.pluginTerminals && window.pluginTerminals.has(domain)) {
|
||||||
|
const term = window.pluginTerminals.get(domain);
|
||||||
|
if (term) {
|
||||||
|
term.dispose();
|
||||||
|
}
|
||||||
|
window.pluginTerminals.delete(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect resize observer
|
||||||
|
if (window.pluginResizeObservers && window.pluginResizeObservers.has(domain)) {
|
||||||
|
const observer = window.pluginResizeObservers.get(domain);
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
window.pluginResizeObservers.delete(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up fitAddon
|
||||||
|
if (window.pluginFitAddons && window.pluginFitAddons.has(domain)) {
|
||||||
|
window.pluginFitAddons.delete(domain);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a plugin
|
||||||
|
async function startPlugin(domain) {
|
||||||
|
// Show logs section
|
||||||
|
showPluginLogs(domain);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/plugins/${domain}/start`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Plugin "${domain}" started successfully`, 'success');
|
||||||
|
}
|
||||||
|
// Refresh plugins list
|
||||||
|
await renderPlugins();
|
||||||
|
// Re-show logs section after refresh (with longer delay to ensure DOM is ready)
|
||||||
|
setTimeout(() => {
|
||||||
|
showPluginLogs(domain);
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(data.error || 'Failed to start plugin', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error starting plugin:', err);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to start plugin', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle plugin enabled/disabled state
|
||||||
|
async function togglePluginEnabled(domain, enabled) {
|
||||||
|
// Prevent toggling system plugins
|
||||||
|
const SYSTEM_PLUGINS = ['p2ns.admin', 'global.profile'];
|
||||||
|
if (SYSTEM_PLUGINS.includes(domain) && !enabled) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Cannot disable system plugin: ${domain}. This plugin is required by the system.`, 'error');
|
||||||
|
}
|
||||||
|
// Refresh to reset toggle state
|
||||||
|
await renderPlugins();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/plugins/${domain}/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Plugin "${domain}" ${enabled ? 'enabled' : 'disabled'} successfully`, 'success');
|
||||||
|
}
|
||||||
|
// Refresh plugins list
|
||||||
|
await renderPlugins();
|
||||||
|
} else {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(data.error || 'Failed to toggle plugin', 'error');
|
||||||
|
}
|
||||||
|
// Refresh to reset toggle state
|
||||||
|
await renderPlugins();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error toggling plugin:', err);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to toggle plugin', 'error');
|
||||||
|
}
|
||||||
|
// Refresh to reset toggle state
|
||||||
|
await renderPlugins();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload a plugin
|
||||||
|
async function reloadPlugin(domain) {
|
||||||
|
const confirmed = await window.ConfirmationModal.warning(
|
||||||
|
`Reload plugin "${domain}"? This will restart the plugin without restarting P2NS.`,
|
||||||
|
{
|
||||||
|
title: 'Reload Plugin',
|
||||||
|
confirmText: 'Reload',
|
||||||
|
cancelText: 'Cancel'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show logs section
|
||||||
|
showPluginLogs(domain);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/plugins/${domain}/reload`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Plugin "${domain}" reloaded successfully`, 'success');
|
||||||
|
}
|
||||||
|
// Refresh plugins list
|
||||||
|
await renderPlugins();
|
||||||
|
// Re-show logs section after refresh (with longer delay to ensure DOM is ready)
|
||||||
|
setTimeout(() => {
|
||||||
|
showPluginLogs(domain);
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(data.error || 'Failed to reload plugin', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reloading plugin:', err);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to reload plugin', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save plugin settings
|
||||||
|
async function savePluginSettings(domain) {
|
||||||
|
try {
|
||||||
|
const settings = {};
|
||||||
|
const inputs = document.querySelectorAll(`[data-plugin-domain="${domain}"][data-setting-key]`);
|
||||||
|
|
||||||
|
inputs.forEach(input => {
|
||||||
|
const key = input.dataset.settingKey;
|
||||||
|
let value;
|
||||||
|
|
||||||
|
if (input.type === 'checkbox') {
|
||||||
|
value = input.checked;
|
||||||
|
} else if (input.type === 'number') {
|
||||||
|
value = parseFloat(input.value);
|
||||||
|
} else {
|
||||||
|
value = input.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`/api/plugins/${domain}/settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(settings)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(`Settings saved for plugin "${domain}"`, 'success');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification(data.error || 'Failed to save settings', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving plugin settings:', err);
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to save settings', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter plugins
|
||||||
|
function filterPlugins() {
|
||||||
|
const query = document.getElementById('search-plugins')?.value.toLowerCase() || '';
|
||||||
|
const cards = document.querySelectorAll('.plugin-card');
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
const domain = card.dataset.pluginDomain;
|
||||||
|
const plugin = pluginsData.find(p => p.domain === domain);
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
card.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchableText = [
|
||||||
|
plugin.name,
|
||||||
|
plugin.domain,
|
||||||
|
plugin.description,
|
||||||
|
plugin.version,
|
||||||
|
plugin.author
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
|
||||||
|
if (searchableText.includes(query)) {
|
||||||
|
card.style.display = '';
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally available
|
||||||
|
window.renderPlugins = renderPlugins;
|
||||||
|
window.executeAction = executeAction;
|
||||||
|
window.stopPlugin = stopPlugin;
|
||||||
|
window.startPlugin = startPlugin;
|
||||||
|
window.reloadPlugin = reloadPlugin;
|
||||||
|
window.savePluginSettings = savePluginSettings;
|
||||||
|
window.filterPlugins = filterPlugins;
|
||||||
|
window.initializePluginTerminal = initializePluginTerminal;
|
||||||
|
window.cleanupPluginTerminal = cleanupPluginTerminal;
|
||||||
|
window.showPluginLogs = showPluginLogs;
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Global state variables
|
||||||
|
window.activeTab = 'domains';
|
||||||
|
window.ws = null;
|
||||||
|
window.wsConnected = false;
|
||||||
|
window.wsReconnectAttempts = 0;
|
||||||
|
window.wsPollingInterval = null;
|
||||||
|
window.statusUpdateInterval = null;
|
||||||
|
window.logBuffer = [];
|
||||||
|
window.maxLogLines = 1000;
|
||||||
|
window.terminalInitialized = false;
|
||||||
|
window.holesailLogBuffers = new Map();
|
||||||
|
window.pluginLogBuffers = new Map();
|
||||||
|
window.pluginTerminals = new Map();
|
||||||
|
window.pluginFitAddons = new Map();
|
||||||
|
window.pluginResizeObservers = new Map();
|
||||||
|
window.term = null;
|
||||||
|
window.fitAddon = null;
|
||||||
|
window.holesailLogBuffers = new Map();
|
||||||
|
window.currentOpenHolesailId = null;
|
||||||
|
window.holesailTerm = null;
|
||||||
|
window.holesailFitAddon = null;
|
||||||
|
window.pendingClientRestarts = new Set();
|
||||||
|
window.pendingServerRestarts = new Set();
|
||||||
|
window.pendingClientDeletions = new Set();
|
||||||
|
window.pendingServerDeletions = new Set();
|
||||||
|
|
||||||
|
// Stats charts
|
||||||
|
window.statsCharts = {};
|
||||||
|
window.statsUpdateInterval = null;
|
||||||
|
window.statsData = null;
|
||||||
|
window.historicalData = null;
|
||||||
|
|
||||||
|
// Local DNS editing state
|
||||||
|
window.editLocalIndex = -1;
|
||||||
|
|
||||||
|
// Settings state
|
||||||
|
window.currentSubnets = [];
|
||||||
|
window.editingSubnetIndex = null;
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// System functions - reset, etc.
|
||||||
|
|
||||||
|
// Reset system
|
||||||
|
function resetSystem() {
|
||||||
|
if (window.showConfirm) {
|
||||||
|
window.showConfirm('Reset the system? This will clear storage and restart internally.', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/reset-system', { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('System reset successfully');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (window.showNotification) {
|
||||||
|
window.showNotification('Failed to reset system: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.resetSystem = resetSystem;
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Utility functions
|
||||||
|
function renderStatusBadge(state) {
|
||||||
|
const badges = {
|
||||||
|
'running': { class: 'bg-green-500', icon: '✓', text: 'Running' },
|
||||||
|
'stopped': { class: 'bg-gray-500', icon: '○', text: 'Stopped' },
|
||||||
|
'starting': { class: 'bg-yellow-500', icon: '⟳', text: 'Starting' },
|
||||||
|
'error': { class: 'bg-red-500', icon: '✗', text: 'Error' }
|
||||||
|
};
|
||||||
|
const badge = badges[state] || badges['stopped'];
|
||||||
|
return `<span class="px-2 py-1 ${badge.class} text-xs font-semibold rounded-full flex items-center gap-1 w-fit" style="color: var(--text-primary);" title="${badge.text}">
|
||||||
|
<span>${badge.icon}</span>
|
||||||
|
<span>${badge.text}</span>
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateUrl(url, maxLength = 40) {
|
||||||
|
if (!url || url === 'N/A') return 'N/A';
|
||||||
|
if (url.length <= maxLength) return url;
|
||||||
|
return url.substring(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatting functions for stats
|
||||||
|
function formatUptime(ms) {
|
||||||
|
const days = Math.floor(ms / 86400000);
|
||||||
|
const hours = Math.floor((ms % 86400000) / 3600000);
|
||||||
|
const minutes = Math.floor((ms % 3600000) / 60000);
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (!ms || ms === 0) return '-';
|
||||||
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
|
||||||
|
if (ms < 3600000) return `${(ms / 60000).toFixed(2)}m`;
|
||||||
|
return `${(ms / 3600000).toFixed(2)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCPUUsage(cpuUsage, uptime) {
|
||||||
|
if (!cpuUsage) return 'N/A';
|
||||||
|
|
||||||
|
// Handle pidusage format (has percentage property)
|
||||||
|
if (typeof cpuUsage.percentage === 'number') {
|
||||||
|
return `${cpuUsage.percentage.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle old format with user/system in microseconds
|
||||||
|
if (typeof cpuUsage.user === 'number' && typeof cpuUsage.system === 'number') {
|
||||||
|
// CPU usage is in microseconds, uptime is in milliseconds
|
||||||
|
// Calculate average CPU percentage over process lifetime
|
||||||
|
if (uptime && uptime > 0) {
|
||||||
|
const uptimeMicroseconds = uptime * 1000; // Convert ms to microseconds
|
||||||
|
const totalCpuMicroseconds = cpuUsage.user + cpuUsage.system;
|
||||||
|
const cpuPercent = (totalCpuMicroseconds / uptimeMicroseconds) * 100;
|
||||||
|
return `${cpuPercent.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
// Fallback: show raw values if no uptime
|
||||||
|
const userMs = (cpuUsage.user / 1000).toFixed(2);
|
||||||
|
const systemMs = (cpuUsage.system / 1000).toFixed(2);
|
||||||
|
return `${userMs}ms user, ${systemMs}ms system`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMemoryUsage(memoryUsage) {
|
||||||
|
if (!memoryUsage) return 'N/A';
|
||||||
|
// Memory usage is in bytes, convert to MB
|
||||||
|
const rssMB = (memoryUsage.rss / 1024 / 1024).toFixed(2);
|
||||||
|
const heapUsedMB = (memoryUsage.heapUsed / 1024 / 1024).toFixed(2);
|
||||||
|
const heapTotalMB = (memoryUsage.heapTotal / 1024 / 1024).toFixed(2);
|
||||||
|
return `${rssMB} MB RSS (${heapUsedMB}/${heapTotalMB} MB heap)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartColors() {
|
||||||
|
return document.documentElement.classList.contains('dark') ? window.darkModeColors : window.chartColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.renderStatusBadge = renderStatusBadge;
|
||||||
|
window.truncateUrl = truncateUrl;
|
||||||
|
window.escapeHtml = escapeHtml;
|
||||||
|
window.formatUptime = formatUptime;
|
||||||
|
window.formatDuration = formatDuration;
|
||||||
|
window.formatCPUUsage = formatCPUUsage;
|
||||||
|
window.formatMemoryUsage = formatMemoryUsage;
|
||||||
|
window.getChartColors = getChartColors;
|
||||||
|
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// WebSocket client management
|
||||||
|
let reconnectTimeout = null;
|
||||||
|
|
||||||
|
function startPollingFallback() {
|
||||||
|
if (window.wsPollingInterval) return;
|
||||||
|
window.wsPollingInterval = setInterval(() => {
|
||||||
|
if (window.activeTab === 'host' && !window.wsConnected) {
|
||||||
|
if (window.genericFetch) {
|
||||||
|
window.genericFetch('host-servers', true);
|
||||||
|
window.genericFetch('host-clients', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPollingFallback() {
|
||||||
|
if (window.wsPollingInterval) {
|
||||||
|
clearInterval(window.wsPollingInterval);
|
||||||
|
window.wsPollingInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWebSocket() {
|
||||||
|
// Close existing connection if any
|
||||||
|
if (window.ws) {
|
||||||
|
try {
|
||||||
|
window.ws.onopen = null;
|
||||||
|
window.ws.onclose = null;
|
||||||
|
window.ws.onerror = null;
|
||||||
|
window.ws.onmessage = null;
|
||||||
|
if (window.ws.readyState === WebSocket.OPEN || window.ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
window.ws.close();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error closing existing WebSocket:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any pending reconnect timeout
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ws = new WebSocket('wss://' + location.host + '/ws');
|
||||||
|
window.ws.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
window.wsConnected = true;
|
||||||
|
window.wsReconnectAttempts = 0;
|
||||||
|
stopPollingFallback();
|
||||||
|
if (window.updateStatus) window.updateStatus();
|
||||||
|
if (window.activeTab === 'host' && window.genericFetch) {
|
||||||
|
window.genericFetch('host-servers', true);
|
||||||
|
window.genericFetch('host-clients', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.ws.onclose = () => {
|
||||||
|
window.wsConnected = false;
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, window.wsReconnectAttempts), 30000);
|
||||||
|
window.wsReconnectAttempts++;
|
||||||
|
reconnectTimeout = setTimeout(connectWebSocket, delay);
|
||||||
|
if (window.activeTab === 'host') {
|
||||||
|
startPollingFallback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.ws.onerror = (err) => {
|
||||||
|
console.error('WebSocket error:', err);
|
||||||
|
window.wsConnected = false;
|
||||||
|
if (window.activeTab === 'host') {
|
||||||
|
startPollingFallback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.ws.onmessage = (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'update-holesail-clients') {
|
||||||
|
if (window.genericFetch) {
|
||||||
|
window.genericFetch('host-clients', window.activeTab === 'host').then(() => {
|
||||||
|
let updated = false;
|
||||||
|
for (const pendingId of [...window.pendingClientRestarts]) {
|
||||||
|
const item = window.holesailClientsData?.find(i => i.id === pendingId);
|
||||||
|
if (item && item.info.state === 'running') {
|
||||||
|
if (window.showNotification) window.showNotification('Holesail client restarted successfully');
|
||||||
|
window.pendingClientRestarts.delete(pendingId);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pendingId of [...window.pendingClientDeletions]) {
|
||||||
|
const item = window.holesailClientsData?.find(i => i.id === pendingId);
|
||||||
|
if (!item) {
|
||||||
|
if (window.showNotification) window.showNotification('Holesail client deleted successfully');
|
||||||
|
window.pendingClientDeletions.delete(pendingId);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated && window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-clients');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === 'update-holesail') {
|
||||||
|
if (window.genericFetch) {
|
||||||
|
window.genericFetch('host-servers', window.activeTab === 'host').then(() => {
|
||||||
|
let updated = false;
|
||||||
|
for (const pendingId of [...window.pendingServerRestarts]) {
|
||||||
|
const item = window.holesailServersData?.find(i => i.id === pendingId);
|
||||||
|
if (item && item.info.state === 'running') {
|
||||||
|
if (window.showNotification) window.showNotification('Holesail server restarted successfully');
|
||||||
|
window.pendingServerRestarts.delete(pendingId);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pendingId of [...window.pendingServerDeletions]) {
|
||||||
|
const item = window.holesailServersData?.find(i => i.id === pendingId);
|
||||||
|
if (!item) {
|
||||||
|
if (window.showNotification) window.showNotification('Holesail server deleted successfully');
|
||||||
|
window.pendingServerDeletions.delete(pendingId);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated && window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||||
|
window.genericRenderPaginated('host-servers');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === 'update-stats' && window.activeTab === 'stats') {
|
||||||
|
if (window.renderStats) window.renderStats();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === 'update-settings' && window.activeTab === 'settings') {
|
||||||
|
if (window.fetchSubnets) window.fetchSubnets();
|
||||||
|
if (window.genericFetch) window.genericFetch('settings', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.updateMap && window.updateMap[data.type]) {
|
||||||
|
if (typeof window.updateMap[data.type] === 'function') {
|
||||||
|
window.updateMap[data.type]();
|
||||||
|
} else {
|
||||||
|
window.updateMap[data.type].forEach(tab => {
|
||||||
|
if (window.activeTab === 'host' || window.activeTab === tab) {
|
||||||
|
if (window.genericFetch) window.genericFetch(tab, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.type === 'log') {
|
||||||
|
window.logBuffer.push(`[${data.level.toUpperCase()}] ${data.message}`);
|
||||||
|
if (window.logBuffer.length > window.maxLogLines) window.logBuffer.shift();
|
||||||
|
if (window.activeTab === 'logs' && window.term) {
|
||||||
|
window.term.writeln(`[${data.level.toUpperCase()}] ${data.message}`);
|
||||||
|
}
|
||||||
|
} else if (data.type === 'holesail-log') {
|
||||||
|
let buffer = window.holesailLogBuffers.get(data.id) || [];
|
||||||
|
buffer.push(`[${data.level.toUpperCase()}] ${data.message}`);
|
||||||
|
if (buffer.length > window.maxLogLines) buffer.shift();
|
||||||
|
window.holesailLogBuffers.set(data.id, buffer);
|
||||||
|
if (window.currentOpenHolesailId === data.id && window.holesailTerm) {
|
||||||
|
window.holesailTerm.writeln(`[${data.level.toUpperCase()}] ${data.message}`);
|
||||||
|
}
|
||||||
|
} else if (data.type === 'plugin-log') {
|
||||||
|
// Initialize plugin log buffers if needed
|
||||||
|
if (!window.pluginLogBuffers) {
|
||||||
|
window.pluginLogBuffers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = data.domain;
|
||||||
|
let buffer = window.pluginLogBuffers.get(domain) || [];
|
||||||
|
const logLine = `[${data.level.toUpperCase()}] [${data.component || 'plugin'}] ${data.message}`;
|
||||||
|
buffer.push(logLine);
|
||||||
|
if (buffer.length > window.maxLogLines || buffer.length > 500) buffer.shift();
|
||||||
|
window.pluginLogBuffers.set(domain, buffer);
|
||||||
|
|
||||||
|
// Write to terminal if plugin card is expanded
|
||||||
|
if (window.pluginTerminals && window.pluginTerminals.has(domain)) {
|
||||||
|
const term = window.pluginTerminals.get(domain);
|
||||||
|
if (term) {
|
||||||
|
// Color code by level
|
||||||
|
const colors = {
|
||||||
|
'DEBUG': '\x1b[90m', // Gray
|
||||||
|
'INFO': '\x1b[0m', // Normal
|
||||||
|
'WARN': '\x1b[33m', // Yellow
|
||||||
|
'ERROR': '\x1b[31m' // Red
|
||||||
|
};
|
||||||
|
const color = colors[data.level.toUpperCase()] || '\x1b[0m';
|
||||||
|
const reset = '\x1b[0m';
|
||||||
|
term.writeln(`${color}${logLine}${reset}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupWebSocket() {
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
if (window.ws) {
|
||||||
|
try {
|
||||||
|
window.ws.onopen = null;
|
||||||
|
window.ws.onclose = null;
|
||||||
|
window.ws.onerror = null;
|
||||||
|
window.ws.onmessage = null;
|
||||||
|
if (window.ws.readyState === WebSocket.OPEN || window.ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
window.ws.close();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error closing WebSocket:', err);
|
||||||
|
}
|
||||||
|
window.ws = null;
|
||||||
|
}
|
||||||
|
window.wsConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/status');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
let text;
|
||||||
|
let color = 'bg-blue-600';
|
||||||
|
if (data.isMaster) {
|
||||||
|
text = `This is Master • Peers: ${data.peersCount}`;
|
||||||
|
} else {
|
||||||
|
if (data.isConnected) {
|
||||||
|
text = `Connected to Master • Peers: ${data.peersCount}`;
|
||||||
|
color = 'bg-blue-500';
|
||||||
|
} else {
|
||||||
|
if (data.peersCount > 0) {
|
||||||
|
text = 'Requesting access...';
|
||||||
|
color = 'bg-blue-300';
|
||||||
|
} else {
|
||||||
|
text = 'Searching for peers...';
|
||||||
|
color = 'bg-blue-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!window.wsConnected) {
|
||||||
|
text += ' (Polling)';
|
||||||
|
color = 'bg-yellow-500';
|
||||||
|
}
|
||||||
|
const indicator = document.getElementById('status-indicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.textContent = text;
|
||||||
|
indicator.className = `px-4 py-2 rounded-lg status-indicator-glass ${color}`;
|
||||||
|
indicator.style.color = 'var(--text-primary)';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch status:', err);
|
||||||
|
const indicator = document.getElementById('status-indicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.textContent = 'Status unknown';
|
||||||
|
indicator.className = 'px-4 py-2 rounded-lg status-indicator-glass bg-blue-900';
|
||||||
|
indicator.style.color = 'var(--text-primary)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStatusUpdates() {
|
||||||
|
if (window.statusUpdateInterval) {
|
||||||
|
clearInterval(window.statusUpdateInterval);
|
||||||
|
}
|
||||||
|
window.statusUpdateInterval = setInterval(updateStatus, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStatusUpdates() {
|
||||||
|
if (window.statusUpdateInterval) {
|
||||||
|
clearInterval(window.statusUpdateInterval);
|
||||||
|
window.statusUpdateInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally accessible
|
||||||
|
window.connectWebSocket = connectWebSocket;
|
||||||
|
window.startPollingFallback = startPollingFallback;
|
||||||
|
window.stopPollingFallback = stopPollingFallback;
|
||||||
|
window.cleanupWebSocket = cleanupWebSocket;
|
||||||
|
window.updateStatus = updateStatus;
|
||||||
|
window.startStatusUpdates = startStatusUpdates;
|
||||||
|
window.stopStatusUpdates = stopStatusUpdates;
|
||||||
|
|
||||||
|
// Initialize WebSocket connection
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', connectWebSocket);
|
||||||
|
} else {
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Main admin entry point - loads all modules and initializes the application
|
||||||
|
// Load order: config -> state -> utils -> core -> notifications -> ws-client -> ui modules -> main init
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
function initializeApp() {
|
||||||
|
// showTab function - must be defined after all modules are loaded
|
||||||
|
function showTab(tabId) {
|
||||||
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||||
|
const tabEl = document.getElementById(tabId);
|
||||||
|
if (tabEl) tabEl.classList.remove('hidden');
|
||||||
|
window.activeTab = tabId;
|
||||||
|
|
||||||
|
if (window.tabs && window.tabs[tabId] && window.genericFetch) {
|
||||||
|
window.genericFetch(tabId, true);
|
||||||
|
}
|
||||||
|
if (tabId === 'host') {
|
||||||
|
if (window.genericFetch) {
|
||||||
|
window.genericFetch('host-servers', true);
|
||||||
|
window.genericFetch('host-clients', true);
|
||||||
|
}
|
||||||
|
if (!window.wsConnected) {
|
||||||
|
if (window.startPollingFallback) window.startPollingFallback();
|
||||||
|
} else {
|
||||||
|
if (window.stopPollingFallback) window.stopPollingFallback();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.stopPollingFallback) window.stopPollingFallback();
|
||||||
|
}
|
||||||
|
if (tabId === 'logs') {
|
||||||
|
if (window.renderLogs) window.renderLogs();
|
||||||
|
}
|
||||||
|
if (tabId === 'stats') {
|
||||||
|
if (window.renderStats) window.renderStats();
|
||||||
|
if (!window.statsUpdateInterval && window.startStatsUpdates) {
|
||||||
|
window.startStatsUpdates();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (window.stopStatsUpdates) window.stopStatsUpdates();
|
||||||
|
}
|
||||||
|
// Note: fetchSubnets() is now called from renderSettings() after the subnet configurator HTML is created
|
||||||
|
// This ensures the DOM elements exist before attempting to populate them
|
||||||
|
}
|
||||||
|
window.showTab = showTab;
|
||||||
|
|
||||||
|
// Filter settings function
|
||||||
|
function filterSettings() {
|
||||||
|
const query = document.getElementById('search-settings')?.value.toLowerCase() || '';
|
||||||
|
const container = document.getElementById('settingsContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const categories = container.querySelectorAll('.settings-category');
|
||||||
|
categories.forEach(category => {
|
||||||
|
const categoryTitle = category.querySelector('h3')?.textContent.toLowerCase() || '';
|
||||||
|
const items = category.querySelectorAll('.settings-item');
|
||||||
|
let categoryVisible = categoryTitle.includes(query);
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const label = item.querySelector('label')?.textContent.toLowerCase() || '';
|
||||||
|
const description = item.querySelector('.settings-description')?.textContent.toLowerCase() || '';
|
||||||
|
const matches = label.includes(query) || description.includes(query);
|
||||||
|
item.style.display = matches ? '' : 'none';
|
||||||
|
if (matches) categoryVisible = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
category.style.display = categoryVisible ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.filterSettings = filterSettings;
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
const hash = location.hash.substring(1);
|
||||||
|
const tabId = hash && document.getElementById(hash) ? hash : 'domains';
|
||||||
|
showTab(tabId);
|
||||||
|
|
||||||
|
if (window.startStatusUpdates) window.startStatusUpdates();
|
||||||
|
|
||||||
|
// Pre-load local-dns data so it's available when the tab is accessed
|
||||||
|
if (window.genericFetch) {
|
||||||
|
window.genericFetch('local-dns', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeApp);
|
||||||
|
} else {
|
||||||
|
initializeApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle hash changes
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
const tabId = location.hash.substring(1);
|
||||||
|
if (tabId && document.getElementById(tabId) && window.showTab) {
|
||||||
|
window.showTab(tabId);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const state = require('../infrastructure/state');
|
||||||
|
const { logDebug, logError, logInfo } = require('../infrastructure/logger');
|
||||||
|
|
||||||
|
const selectorCacheFile = process.env.SELECTOR_CACHE_FILE || './cache/selector_cache.json';
|
||||||
|
const localDnsFile = process.env.LOCAL_DNS_FILE || 'cache/local_dns.json';
|
||||||
|
|
||||||
|
async function loadSelectorCache() {
|
||||||
|
try {
|
||||||
|
if (await fs.access(selectorCacheFile).then(() => true).catch(() => false)) {
|
||||||
|
const data = JSON.parse(await fs.readFile(selectorCacheFile, 'utf8'));
|
||||||
|
state.versionPreferences = new Map(Object.entries(data));
|
||||||
|
logInfo('Admin', 'Loaded version preferences from selector_cache.json');
|
||||||
|
} else {
|
||||||
|
state.versionPreferences = new Map();
|
||||||
|
logInfo('Admin', 'No selector_cache.json found, initializing empty version preferences');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to load selector_cache.json: ${err.message}`);
|
||||||
|
state.versionPreferences = new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelectorCache() {
|
||||||
|
try {
|
||||||
|
const data = Object.fromEntries(state.versionPreferences);
|
||||||
|
await fs.writeFile(selectorCacheFile, JSON.stringify(data, null, 2));
|
||||||
|
logDebug('Admin', 'Saved version preferences to selector_cache.json');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to save selector_cache.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalDnsRecords() {
|
||||||
|
try {
|
||||||
|
if (await fs.access(localDnsFile).then(() => true).catch(() => false)) {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(localDnsFile, 'utf8'));
|
||||||
|
// Ensure parsed result is an array
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
logError('Admin', `Local DNS records file does not contain an array, resetting to empty array`);
|
||||||
|
state.localDnsRecords = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ensure each record has a 'type' and 'class' field
|
||||||
|
state.localDnsRecords = parsed.map(record => ({
|
||||||
|
...record,
|
||||||
|
class: record.class || 'IN',
|
||||||
|
type: record.type || 'A' // Default to A if type is missing
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
state.localDnsRecords = [];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to load local DNS records: ${err.message}`);
|
||||||
|
state.localDnsRecords = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on load
|
||||||
|
loadSelectorCache();
|
||||||
|
loadLocalDnsRecords();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadSelectorCache,
|
||||||
|
saveSelectorCache,
|
||||||
|
loadLocalDnsRecords
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const pathModule = require('path');
|
||||||
|
const child_process = require('child_process');
|
||||||
|
const state = require('../infrastructure/state');
|
||||||
|
const { logDebug, logError, logInfo } = require('../infrastructure/logger');
|
||||||
|
const { createInterfaceForDomain } = require('../networking/virtual_interfaces');
|
||||||
|
const { ensurePortFree } = require('./port-management');
|
||||||
|
const { startHolesailClient } = require('./admin-holesail');
|
||||||
|
const { broadcast } = require('./websocket');
|
||||||
|
|
||||||
|
const holesailClientsFile = process.env.HOLESAIL_CLIENTS_FILE || './cache/holesail_clients.json';
|
||||||
|
|
||||||
|
async function loadHolesailClients() {
|
||||||
|
state.holesailClientChildren = new Map();
|
||||||
|
state.holesailClientOpts = new Map();
|
||||||
|
state.holesailClientInfos = new Map();
|
||||||
|
const file = holesailClientsFile;
|
||||||
|
try {
|
||||||
|
if (await fs.access(file).then(() => true).catch(() => false)) {
|
||||||
|
const data = JSON.parse(await fs.readFile(file, 'utf8'));
|
||||||
|
const promises = (data.clients || []).map(async (s) => {
|
||||||
|
const id = s.id;
|
||||||
|
const opts = s.opts;
|
||||||
|
try {
|
||||||
|
if (!state.domainToIPMap.has(opts.domain)) {
|
||||||
|
await createInterfaceForDomain(opts.domain);
|
||||||
|
logDebug('Holesail', `Assigned IP to ${opts.domain} for client ${id}: ${state.domainToIPMap.get(opts.domain)}`);
|
||||||
|
}
|
||||||
|
const ip = state.domainToIPMap.get(opts.domain);
|
||||||
|
const portFree = await ensurePortFree(ip, opts.port);
|
||||||
|
if (!portFree) {
|
||||||
|
throw new Error(`Unable to ensure port ${opts.port} free on ${ip}`);
|
||||||
|
}
|
||||||
|
await startForkedHolesailClient(id, opts);
|
||||||
|
logInfo('Holesail', `Restored client ${id} for domain ${opts.domain} on port ${opts.port}`);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to restore client ${id} for ${opts.domain}:${opts.port}: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
} else {
|
||||||
|
logInfo('Holesail', 'No holesail_clients.json found, skipping restore');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to load holesail_clients.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startForkedHolesailClient(id, opts) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!state.domainToIPMap.has(opts.domain)) {
|
||||||
|
reject(new Error(`No IP assigned for domain ${opts.domain}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ip = state.domainToIPMap.get(opts.domain);
|
||||||
|
const childOpts = {
|
||||||
|
client: true,
|
||||||
|
key: opts.key,
|
||||||
|
port: opts.port,
|
||||||
|
host: ip,
|
||||||
|
log: false,
|
||||||
|
protocol: opts.protocol || 'tcp'
|
||||||
|
};
|
||||||
|
const child = child_process.fork(pathModule.join(__dirname, '..', 'networking', 'holesail_child.js'));
|
||||||
|
child.on('error', (err) => {
|
||||||
|
logError('Holesail', `Child error for client ${id}: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
logInfo('Holesail', `Child exited for client ${id} with code ${code}`);
|
||||||
|
// Remove all event listeners to prevent leaks
|
||||||
|
child.removeAllListeners();
|
||||||
|
state.holesailClientChildren.delete(id);
|
||||||
|
state.holesailClientInfos.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
});
|
||||||
|
child.on('message', async (msg) => {
|
||||||
|
if (msg.type === 'ready') {
|
||||||
|
try {
|
||||||
|
state.holesailClientInfos.set(id, msg.info);
|
||||||
|
await startHolesailClient(opts.domain, opts.key, ip, opts.port, true, opts.protocol);
|
||||||
|
state.holesailClientChildren.set(id, child);
|
||||||
|
state.holesailClientOpts.set(id, opts);
|
||||||
|
state.holesailChildStartTimes.set(id, Date.now());
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
resolve({ id, info: msg.info });
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to start Holesail client for ${id}: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'log') {
|
||||||
|
broadcast({ type: 'holesail-log', id, level: msg.level, message: msg.message });
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
logError('Holesail', `Child error message for client ${id}: ${msg.message}`);
|
||||||
|
reject(new Error(msg.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.send({ type: 'start', opts: childOpts });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHolesailClients() {
|
||||||
|
const file = holesailClientsFile;
|
||||||
|
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => ({ id, opts }));
|
||||||
|
try {
|
||||||
|
await fs.writeFile(file, JSON.stringify({ clients }, null, 2));
|
||||||
|
logDebug('Holesail', 'Saved holesail_clients.json');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to save holesail_clients.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadHolesailClients,
|
||||||
|
startForkedHolesailClient,
|
||||||
|
saveHolesailClients
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const pathModule = require('path');
|
||||||
|
const child_process = require('child_process');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const z32 = require('z32');
|
||||||
|
const libKeys = require('hyper-cmd-lib-keys');
|
||||||
|
const state = require('../infrastructure/state');
|
||||||
|
const { logDebug, logError, logInfo } = require('../infrastructure/logger');
|
||||||
|
const { trackHolesailEvent } = require('../maintenance/metrics');
|
||||||
|
const { broadcast } = require('./websocket');
|
||||||
|
|
||||||
|
const holesailServersFile = process.env.HOLESAIL_SERVERS_FILE || './cache/holesail_servers.json';
|
||||||
|
|
||||||
|
async function loadHolesailServers() {
|
||||||
|
state.holesailChildren = new Map();
|
||||||
|
state.holesailOpts = new Map();
|
||||||
|
state.holesailInfos = new Map();
|
||||||
|
const file = holesailServersFile;
|
||||||
|
try {
|
||||||
|
if (await fs.access(file).then(() => true).catch(() => false)) {
|
||||||
|
const data = JSON.parse(await fs.readFile(file, 'utf8'));
|
||||||
|
const promises = (data.servers || []).map(async (s) => {
|
||||||
|
const id = s.id;
|
||||||
|
const opts = s.opts;
|
||||||
|
try {
|
||||||
|
logDebug('Admin', `Starting Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`);
|
||||||
|
await startHolesailServer(id, opts);
|
||||||
|
logInfo('Holesail', `Restored server ${id} (${opts.name || 'unnamed'}) on port ${opts.port}`);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to restore server ${id} on port ${opts.port}: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
|
} else {
|
||||||
|
logInfo('Holesail', 'No holesail_servers.json found, skipping restore');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to load holesail_servers.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHolesailServer(id, opts) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!opts.key) {
|
||||||
|
if (opts.secure) {
|
||||||
|
opts.key = libKeys.randomBytes(32).toString('hex');
|
||||||
|
} else {
|
||||||
|
opts.key = z32.encode(crypto.randomBytes(32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const child = child_process.fork(pathModule.join(__dirname, '..', 'networking', 'holesail_child.js'));
|
||||||
|
child.on('error', (err) => {
|
||||||
|
logError('Holesail', `Child error for server ${id}: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
logInfo('Holesail', `Child exited for server ${id} with code ${code}`);
|
||||||
|
// Remove all event listeners to prevent leaks
|
||||||
|
child.removeAllListeners();
|
||||||
|
const opts = state.holesailOpts.get(id);
|
||||||
|
const protocol = opts?.udp ? 'udp' : 'tcp';
|
||||||
|
trackHolesailEvent('server', 'stop', protocol, null);
|
||||||
|
state.holesailChildren.delete(id);
|
||||||
|
state.holesailInfos.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
broadcast({ type: 'update-stats' });
|
||||||
|
});
|
||||||
|
child.on('message', (msg) => {
|
||||||
|
if (msg.type === 'ready') {
|
||||||
|
state.holesailInfos.set(id, msg.info);
|
||||||
|
const protocol = opts.udp ? 'udp' : 'tcp';
|
||||||
|
trackHolesailEvent('server', 'start', protocol, null);
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
broadcast({ type: 'update-stats' });
|
||||||
|
resolve({ id, info: msg.info });
|
||||||
|
} else if (msg.type === 'log') {
|
||||||
|
broadcast({ type: 'holesail-log', id, level: msg.level, message: msg.message });
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
logError('Holesail', `Child error message for server ${id}: ${msg.message}`);
|
||||||
|
reject(new Error(msg.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.send({ type: 'start', opts: { server: true, ...opts, log: false } });
|
||||||
|
state.holesailChildren.set(id, child);
|
||||||
|
state.holesailOpts.set(id, opts);
|
||||||
|
state.holesailChildStartTimes.set(id, Date.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHolesailServers() {
|
||||||
|
const file = holesailServersFile;
|
||||||
|
const servers = Array.from(state.holesailOpts.entries()).map(([id, opts]) => ({ id, opts }));
|
||||||
|
try {
|
||||||
|
await fs.writeFile(file, JSON.stringify({ servers }, null, 2));
|
||||||
|
logDebug('Holesail', 'Saved holesail_servers.json');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Holesail', `Failed to save holesail_servers.json: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadHolesailServers,
|
||||||
|
startHolesailServer,
|
||||||
|
saveHolesailServers
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,800 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>P2NS Admin Panel</title>
|
||||||
|
<link rel="stylesheet" href="/tailwind.css">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.min.js"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/css/xterm.min.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white min-h-screen transition-colors duration-300">
|
||||||
|
<div class="container mx-auto p-6 max-w-7xl">
|
||||||
|
<h1 class="text-4xl font-extrabold text-center mb-8">P2NS Admin Panel</h1>
|
||||||
|
|
||||||
|
<nav class="flex justify-center mb-8 space-x-4 flex-wrap">
|
||||||
|
<button onclick="location.hash = 'domains';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Domains</button>
|
||||||
|
<button onclick="location.hash = 'host';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Host</button>
|
||||||
|
<button onclick="location.hash = 'local-dns';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Local DNS</button>
|
||||||
|
<button onclick="location.hash = 'entries';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Entries</button>
|
||||||
|
<button onclick="location.hash = 'peers';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Peers</button>
|
||||||
|
<button onclick="location.hash = 'certs';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Certificates</button>
|
||||||
|
<button onclick="location.hash = 'interfaces';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Interfaces</button>
|
||||||
|
<button onclick="location.hash = 'logs';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Logs</button>
|
||||||
|
<button onclick="location.hash = 'stats';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Stats</button>
|
||||||
|
<button onclick="location.hash = 'settings';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Settings</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="domains" class="tab-content hidden">
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
Domains
|
||||||
|
<button onclick="openInfoModal('domains')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="mb-6">
|
||||||
|
<input id="search-domains" type="text" placeholder="Search domains..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterDomains()">
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||||
|
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 text-left">Domain</th>
|
||||||
|
<th class="p-3 text-left">Hash</th>
|
||||||
|
<th class="p-3 text-left">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="domainsTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="domainsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||||
|
<button onclick="openAddModal()" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add Domain</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="local-dns" class="tab-content hidden">
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
Custom Local DNS Records
|
||||||
|
<button onclick="openInfoModal('local-dns')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="mb-6">
|
||||||
|
<input id="search-local-dns" type="text" placeholder="Search records..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterLocalDNS()">
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||||
|
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 text-left">Name</th>
|
||||||
|
<th class="p-3 text-left">Type</th>
|
||||||
|
<th class="p-3 text-left">Value</th>
|
||||||
|
<th class="p-3 text-left">TTL</th>
|
||||||
|
<th class="p-3 text-left">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="localDnsTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="localDnsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||||
|
<button onclick="openLocalDnsModal()" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add Record</button>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
|
||||||
|
DNS Conflict Selector
|
||||||
|
<button onclick="openInfoModal('dns-conflicts')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="mb-6">
|
||||||
|
<input id="search-dns-conflicts" type="text" placeholder="Search conflicts..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterDnsConflicts()">
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||||
|
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 text-left">Domain</th>
|
||||||
|
<th class="p-3 text-left">Public IP</th>
|
||||||
|
<th class="p-3 text-left">Mode</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dnsConflictsTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="dnsConflictsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="entries" class="tab-content hidden">
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
Autopass Entries
|
||||||
|
<button onclick="openInfoModal('entries')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="mb-6">
|
||||||
|
<input id="search-entries" type="text" placeholder="Search entries..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterEntries()">
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||||
|
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 text-left">Key</th>
|
||||||
|
<th class="p-3 text-left">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="entriesTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="entriesPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="peers" class="tab-content hidden">
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
Connected Peers <span id="peers-count" class="text-lg"></span>
|
||||||
|
<button onclick="openInfoModal('peers')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="mb-6">
|
||||||
|
<input id="search-peers" type="text" placeholder="Search peers..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterPeers()">
|
||||||
|
</div>
|
||||||
|
<ul id="peersList" class="space-y-3"></ul>
|
||||||
|
<div id="peersPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="certs" class="tab-content hidden">
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
Domain Certificates
|
||||||
|
<button onclick="openInfoModal('certs')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="mb-6">
|
||||||
|
<input id="search-certs" type="text" placeholder="Search certificates..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterCerts()">
|
||||||
|
</div>
|
||||||
|
<ul id="certsList" class="space-y-3"></ul>
|
||||||
|
<div id="certsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<input id="cert-domain" placeholder="Domain for Cert" class="p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<button onclick="generateCert()" class="ml-2 px-4 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover">Generate Cert</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
|
||||||
|
CA Management
|
||||||
|
<button onclick="openInfoModal('ca-management')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<button onclick="regenerateCA()" class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover mr-4">Regenerate Root CA</button>
|
||||||
|
<button onclick="installCA()" class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover">Install Root CA</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="interfaces" class="tab-content hidden flex flex-col overflow-hidden">
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||||
|
Virtual Interfaces
|
||||||
|
<button onclick="openInfoModal('interfaces')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="mb-6 flex-shrink-0">
|
||||||
|
<input id="search-interfaces" type="text" placeholder="Search interfaces..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterInterfaces()">
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||||
|
<div class="overflow-x-auto overflow-y-auto flex-1">
|
||||||
|
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||||
|
<thead class="bg-gray-200 dark:bg-gray-700 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 text-left">Domain</th>
|
||||||
|
<th class="p-3 text-left">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="interfacesTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="interfacesPagination" class="flex justify-center mt-4 space-x-2 flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="logs" class="tab-content hidden">
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
Logs
|
||||||
|
<button onclick="openInfoModal('logs')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div id="terminal" class="bg-black rounded-lg overflow-hidden h-96"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="host" class="tab-content hidden">
|
||||||
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
Holesail Servers
|
||||||
|
<button onclick="openInfoModal('holesail-servers')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="mb-6">
|
||||||
|
<input id="search-holesail" type="text" placeholder="Search servers..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterHolesailServers()">
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||||
|
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 text-left">Name/ID</th>
|
||||||
|
<th class="p-3 text-left">Port</th>
|
||||||
|
<th class="p-3 text-left">Host</th>
|
||||||
|
<th class="p-3 text-left">URL</th>
|
||||||
|
<th class="p-3 text-left">Protocol</th>
|
||||||
|
<th class="p-3 text-left">Status</th>
|
||||||
|
<th class="p-3 text-left">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="holesailTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="holesailPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||||
|
<button onclick="openCreateHolesailModal()" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Create Server</button>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
|
||||||
|
Holesail Clients
|
||||||
|
<button onclick="openInfoModal('holesail-clients')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="mb-6">
|
||||||
|
<input id="search-holesail-clients" type="text" placeholder="Search clients..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterHolesailClients()">
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||||
|
<thead class="bg-gray-200 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="p-3 text-left">Domain</th>
|
||||||
|
<th class="p-3 text-left">Key</th>
|
||||||
|
<th class="p-3 text-left">Port</th>
|
||||||
|
<th class="p-3 text-left">Protocol</th>
|
||||||
|
<th class="p-3 text-left">Status</th>
|
||||||
|
<th class="p-3 text-left">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="holesailClientsTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="holesailClientsPagination" class="flex justify-center mt-4 space-x-2"></div>
|
||||||
|
<button onclick="openCreateClientModal()" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Create Client</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings" class="tab-content hidden">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||||
|
Settings
|
||||||
|
<button onclick="openInfoModal('settings')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<button onclick="saveSettings()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<input id="search-settings" type="text" placeholder="Search settings..." class="w-full p-3 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterSettings()">
|
||||||
|
</div>
|
||||||
|
<div id="settingsContainer" class="space-y-6"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stats" class="tab-content hidden">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||||
|
Statistics & Metrics
|
||||||
|
<button onclick="openInfoModal('stats')" class="text-sm px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Info</button>
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" id="auto-refresh-stats" checked class="mr-2">
|
||||||
|
<span>Auto-refresh:</span>
|
||||||
|
</label>
|
||||||
|
<select id="refresh-interval-selector" class="px-3 py-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg">
|
||||||
|
<option value="1000">Realtime (1s)</option>
|
||||||
|
<option value="2000">Fast (2s)</option>
|
||||||
|
<option value="5000" selected>Normal (5s)</option>
|
||||||
|
<option value="10000">Slow (10s)</option>
|
||||||
|
<option value="30000">Very Slow (30s)</option>
|
||||||
|
</select>
|
||||||
|
<select id="time-range-selector" class="px-3 py-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg">
|
||||||
|
<option value="60">Last 1 hour</option>
|
||||||
|
<option value="360">Last 6 hours</option>
|
||||||
|
<option value="1440" selected>Last 24 hours</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="exportStats()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Export Data</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Overview Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 overflow-hidden min-h-[140px] flex flex-col">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">System Uptime</h3>
|
||||||
|
<p id="uptime-display" class="text-2xl font-bold whitespace-nowrap">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 overflow-hidden min-h-[140px] flex flex-col">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Node Type</h3>
|
||||||
|
<p id="node-type-display" class="text-2xl font-bold mb-1 whitespace-nowrap">-</p>
|
||||||
|
<p id="connection-status" class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 overflow-hidden min-h-[140px] flex flex-col">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Connected Peers</h3>
|
||||||
|
<p id="peers-current" class="text-2xl font-bold whitespace-nowrap">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 overflow-hidden min-h-[140px] flex flex-col">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Total Domains</h3>
|
||||||
|
<p id="domains-current" class="text-2xl font-bold whitespace-nowrap">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DNS Statistics Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">DNS Statistics</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Queries</p>
|
||||||
|
<p id="dns-queries-total" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Success Rate</p>
|
||||||
|
<p id="dns-success-rate" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Avg Response Time</p>
|
||||||
|
<p id="dns-avg-response" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">P2P Rate</p>
|
||||||
|
<p id="dns-p2p-rate" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<canvas id="dns-queries-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="dns-types-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<h4 class="text-lg font-semibold mb-2">Top Queried Domains</h4>
|
||||||
|
<div id="top-domains-list" class="space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network & Peers Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Network & Peers</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Connected</p>
|
||||||
|
<p id="peers-connected-count" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Connections</p>
|
||||||
|
<p id="peers-total-connections" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Avg Duration</p>
|
||||||
|
<p id="peers-avg-duration" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="peer-events-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Holesail Statistics Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Holesail Statistics</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Active Connections</p>
|
||||||
|
<p id="holesail-active" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Clients Started</p>
|
||||||
|
<p id="holesail-clients-started" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Servers Started</p>
|
||||||
|
<p id="holesail-servers-started" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Avg Duration</p>
|
||||||
|
<p id="holesail-avg-duration" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<canvas id="holesail-events-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="holesail-protocol-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Holesail Children Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Holesail Children</h3>
|
||||||
|
<div id="holesail-children-container" class="space-y-4">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-center">Loading children data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API & Requests Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">API & Requests</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Requests</p>
|
||||||
|
<p id="requests-total" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Success Rate</p>
|
||||||
|
<p id="requests-success-rate" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Avg Response Time</p>
|
||||||
|
<p id="requests-avg-response" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Failed Requests</p>
|
||||||
|
<p id="requests-failed" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<canvas id="requests-timeline-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="endpoint-usage-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resource Usage Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Resource Usage</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Active Sockets</p>
|
||||||
|
<p id="resources-sockets" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Active Servers</p>
|
||||||
|
<p id="resources-servers" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Holesail Connections</p>
|
||||||
|
<p id="resources-holesails" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Peer Channels</p>
|
||||||
|
<p id="resources-channels" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="resources-timeline-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Domain Management Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Domain Management</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Added</p>
|
||||||
|
<p id="domains-added" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Removed</p>
|
||||||
|
<p id="domains-removed" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Net Change</p>
|
||||||
|
<p id="domains-net" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="domain-events-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Process Usage Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Process Usage Statistics</h3>
|
||||||
|
|
||||||
|
<!-- Process Info Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Node.js Version</h4>
|
||||||
|
<p id="process-node-version" class="text-lg font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Platform</h4>
|
||||||
|
<p id="process-platform" class="text-lg font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Architecture</h4>
|
||||||
|
<p id="process-arch" class="text-lg font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">Process Uptime</h4>
|
||||||
|
<p id="process-uptime" class="text-lg font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory Usage -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-semibold mb-4">Memory Usage</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Heap Used</p>
|
||||||
|
<p id="process-memory-heap-used" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Heap Total</p>
|
||||||
|
<p id="process-memory-heap-total" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">RSS</p>
|
||||||
|
<p id="process-memory-rss" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">External</p>
|
||||||
|
<p id="process-memory-external" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Array Buffers</p>
|
||||||
|
<p id="process-memory-array-buffers" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="process-memory-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CPU Usage -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-semibold mb-4">CPU Usage</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Current CPU %</p>
|
||||||
|
<p id="process-cpu-current" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Average CPU %</p>
|
||||||
|
<p id="process-cpu-avg" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Active Handles</p>
|
||||||
|
<p id="process-handles-active" class="text-2xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="process-cpu-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Resources -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-semibold mb-4">System Resources</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Memory</p>
|
||||||
|
<p id="process-system-total-memory" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Free Memory</p>
|
||||||
|
<p id="process-system-free-memory" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Used Memory %</p>
|
||||||
|
<p id="process-system-used-percent" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Load Average</p>
|
||||||
|
<p id="process-system-load-avg" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="process-system-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Loop Performance -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-semibold mb-4">Event Loop Performance</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Current Lag</p>
|
||||||
|
<p id="process-eventloop-current" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Average Lag</p>
|
||||||
|
<p id="process-eventloop-avg" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Max Lag</p>
|
||||||
|
<p id="process-eventloop-max" class="text-xl font-bold">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<canvas id="process-eventloop-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="addDomainModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Add New Domain</h3>
|
||||||
|
<input id="modal-domain" placeholder="Domain" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<input id="modal-hash" placeholder="Hash" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<label class="flex items-center mb-4">
|
||||||
|
<input id="modal-ssl" type="checkbox" class="mr-2">
|
||||||
|
<span>This Holesail Connection Uses SSL/TLS</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button onclick="document.getElementById('addDomainModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
|
||||||
|
<button onclick="submitAddDomain()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="localDnsModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<h3 id="local-dns-title" class="text-xl font-bold mb-4">Add Local DNS Record</h3>
|
||||||
|
<input id="local-name" placeholder="Name (domain)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<select id="local-type" onchange="updateLocalForm()" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<option value="A">A</option>
|
||||||
|
<option value="AAAA">AAAA</option>
|
||||||
|
<option value="CNAME">CNAME</option>
|
||||||
|
<option value="MX">MX</option>
|
||||||
|
<option value="TXT">TXT</option>
|
||||||
|
<option value="SRV">SRV</option>
|
||||||
|
<option value="SOA">SOA</option>
|
||||||
|
<option value="CAA">CAA</option>
|
||||||
|
<option value="NS">NS</option>
|
||||||
|
<option value="PTR">PTR</option>
|
||||||
|
<option value="OTHER">OTHER</option>
|
||||||
|
</select>
|
||||||
|
<div id="local-value-fields" class="mb-4"></div>
|
||||||
|
<input id="local-ttl" type="number" placeholder="TTL" value="3600" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button onclick="document.getElementById('localDnsModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
|
||||||
|
<button id="local-submit" onclick="submitLocalDns()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="certDetailsModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Certificate Details</h3>
|
||||||
|
<button onclick="document.getElementById('certDetailsModal').close()" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-3xl leading-none font-bold">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto mb-4">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<pre id="cert-details-content" class="whitespace-pre-wrap break-all text-sm font-mono text-gray-800 dark:text-gray-200 leading-relaxed"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button onclick="copyCertDetails()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors">Copy</button>
|
||||||
|
<button onclick="document.getElementById('certDetailsModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors">Close</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Confirmation modal is created dynamically by confirmation-modal.js -->
|
||||||
|
|
||||||
|
<dialog id="createHolesailModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Create Holesail Server</h3>
|
||||||
|
<input id="holesail-name" placeholder="Name (optional)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<input id="holesail-port" type="number" placeholder="Port (required)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<input id="holesail-host" placeholder="Host (default 0.0.0.0)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<input id="holesail-key" placeholder="Key (optional)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<input id="holesail-domain" placeholder="Assign to domain (optional)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<label class="flex items-center mb-4"><input id="holesail-secure" type="checkbox" class="mr-2"> Secure</label>
|
||||||
|
<label class="flex items-center mb-4"><input id="holesail-udp" type="checkbox" checked class="mr-2"> UDP</label>
|
||||||
|
<select id="holesail-log" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<option value="false">No Logs</option>
|
||||||
|
<option value="0">Debug</option>
|
||||||
|
<option value="1" selected>Info</option>
|
||||||
|
<option value="2">Warn</option>
|
||||||
|
<option value="3">Error</option>
|
||||||
|
</select>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button onclick="document.getElementById('createHolesailModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
|
||||||
|
<button id="create-holesail-btn" onclick="submitCreateHolesail()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Create</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="createClientModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Create Holesail Client</h3>
|
||||||
|
<select id="client-domain" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"></select>
|
||||||
|
<input id="client-key" placeholder="Key (required)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<input id="client-port" type="number" placeholder="Local Port (required)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button onclick="document.getElementById('createClientModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
|
||||||
|
<button id="create-client-btn" onclick="submitCreateClient()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Create</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="holesailLogModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl">
|
||||||
|
<h3 id="holesail-log-title" class="text-xl font-bold mb-4">Holesail Logs</h3>
|
||||||
|
<div id="holesail-terminal" class="bg-black rounded-lg overflow-hidden h-96"></div>
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<button onclick="document.getElementById('holesailLogModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Close</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="infoModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h3 id="info-modal-title" class="text-2xl font-bold"></h3>
|
||||||
|
<button onclick="document.getElementById('infoModal').close()" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-2xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="info-modal-content" class="mb-6 space-y-4"></div>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button onclick="document.getElementById('infoModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Close</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="subnetModal" class="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
|
||||||
|
<h3 id="subnet-modal-title" class="text-xl font-bold mb-4">Add Subnet</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subnet-name" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Subnet Name</label>
|
||||||
|
<input id="subnet-name" placeholder="e.g., Primary Subnet" class="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A descriptive name for this subnet</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subnet-base" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Base IP Address</label>
|
||||||
|
<input id="subnet-base" placeholder="e.g., 192.168.3.0" class="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The network address of the subnet (typically ends in .0)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subnet-cidr" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">CIDR Notation</label>
|
||||||
|
<input id="subnet-cidr" type="number" min="1" max="32" placeholder="24" value="24" class="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Subnet mask in CIDR notation (1-32). /24 = 255.255.255.0 (254 usable IPs)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="subnet-startIndex" class="block text-sm font-medium text-gray-900 dark:text-white mb-2">Start IP Index</label>
|
||||||
|
<input id="subnet-startIndex" type="number" min="1" max="254" placeholder="2" value="2" class="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">First usable IP address index (1-254). IPs will be assigned starting from this number</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button onclick="document.getElementById('subnetModal').close()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white rounded hover:bg-gray-400 dark:hover:bg-gray-500">Cancel</button>
|
||||||
|
<button id="subnet-submit" onclick="submitSubnet()" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status-indicator" class="fixed top-4 right-4 px-4 py-2 bg-blue-500 text-white rounded-lg shadow-md"></div>
|
||||||
|
<div id="notifications" class="fixed bottom-4 right-4 flex flex-col-reverse space-y-2 z-50"></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/addon-fit.js"></script>
|
||||||
|
<!-- Load modules in order: config -> state -> utils -> core -> notifications -> ws-client -> ui modules -> main -->
|
||||||
|
<script src="ui/config.js"></script>
|
||||||
|
<script src="ui/state.js"></script>
|
||||||
|
<script src="utils.js"></script>
|
||||||
|
<script src="ui/core.js"></script>
|
||||||
|
<script src="ui/notifications.js"></script>
|
||||||
|
<script src="ui/confirmation-modal.js"></script>
|
||||||
|
<script src="ws-client.js"></script>
|
||||||
|
<script src="ui/domains.js"></script>
|
||||||
|
<script src="ui/certs.js"></script>
|
||||||
|
<script src="ui/interfaces.js"></script>
|
||||||
|
<script src="ui/local-dns.js"></script>
|
||||||
|
<!-- Note: holesail, settings, logs, stats modules still need to be extracted from original -->
|
||||||
|
<script src="admin.js"></script>
|
||||||
|
<script>
|
||||||
|
function handleHashChange() {
|
||||||
|
var tabId = location.hash.substring(1);
|
||||||
|
if (tabId && document.getElementById(tabId) && typeof showTab === 'function') {
|
||||||
|
showTab(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', handleHashChange);
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
// Wait for admin.js to load
|
||||||
|
if (typeof showTab === 'function') {
|
||||||
|
var tabId = location.hash.substring(1);
|
||||||
|
if (tabId && document.getElementById(tabId)) {
|
||||||
|
showTab(tabId);
|
||||||
|
} else {
|
||||||
|
showTab('domains');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Retry after a short delay if script hasn't loaded
|
||||||
|
setTimeout(function() {
|
||||||
|
if (typeof showTab === 'function') {
|
||||||
|
var tabId = location.hash.substring(1);
|
||||||
|
if (tabId && document.getElementById(tabId)) {
|
||||||
|
showTab(tabId);
|
||||||
|
} else {
|
||||||
|
showTab('domains');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
const net = require('net');
|
||||||
|
const dgram = require('dgram');
|
||||||
|
const { cleanupInterfaces, freePort } = require('../maintenance/cleanup');
|
||||||
|
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
|
||||||
|
|
||||||
|
async function waitForPortRelease(host, port, maxAttempts = 10, delayMs = 1000) {
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', (err) => {
|
||||||
|
server.close();
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`TCP port ${port} on ${host} is already in use`));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
server.listen(port, host);
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
socket.once('error', (err) => {
|
||||||
|
socket.close();
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`UDP port ${port} on ${host} is already in use`));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.once('listening', () => {
|
||||||
|
socket.close();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
socket.bind(port, host);
|
||||||
|
});
|
||||||
|
logDebug('Admin', `Port ${port} on ${host} is now free (attempt ${attempt})`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logDebug('Admin', `Port ${port} on ${host} still in use (attempt ${attempt}): ${err.message}`);
|
||||||
|
if (attempt === maxAttempts) {
|
||||||
|
logWarn('Admin', `Port ${port} on ${host} still in use after ${maxAttempts} attempts`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPortAvailability(host, port) {
|
||||||
|
const tcpPromise = new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', (err) => {
|
||||||
|
server.close();
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`TCP port ${port} on ${host} is already in use`));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.once('listening', () => {
|
||||||
|
server.close(() => {
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(port, host);
|
||||||
|
});
|
||||||
|
const udpPromise = new Promise((resolve, reject) => {
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
socket.once('error', (err) => {
|
||||||
|
socket.close();
|
||||||
|
if (err.code === 'EADDRINUSE') {
|
||||||
|
reject(new Error(`UDP port ${port} on ${host} is already in use`));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.once('listening', () => {
|
||||||
|
socket.close();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
socket.bind(port, host);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await Promise.all([tcpPromise, udpPromise]);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePortFree(host, port) {
|
||||||
|
const maxAttempts = 3;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
await checkPortAvailability(host, port);
|
||||||
|
logInfo('Admin', `Port ${port} on ${host} is free`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logWarn('Admin', `Port ${port} on ${host} in use (attempt ${attempt}/${maxAttempts}): ${err.message}. Attempting to free it.`);
|
||||||
|
const freed = await freePort(host, port);
|
||||||
|
if (!freed) {
|
||||||
|
logError('Admin', `Failed to free port ${port} on ${host} on attempt ${attempt}`);
|
||||||
|
if (attempt === maxAttempts) {
|
||||||
|
logError('Admin', `Port ${port} on ${host} could not be freed after ${maxAttempts} attempts`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logInfo('Admin', `Freed port ${port} on ${host}. Waiting for release...`);
|
||||||
|
const released = await waitForPortRelease(host, port, 10, 1000);
|
||||||
|
if (!released) {
|
||||||
|
logWarn('Admin', `Port ${port} on ${host} still not released after waiting on attempt ${attempt}`);
|
||||||
|
if (attempt === maxAttempts) {
|
||||||
|
logError('Admin', `Port ${port} on ${host} could not be released after ${maxAttempts} attempts`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logInfo('Admin', `Port ${port} on ${host} successfully released on attempt ${attempt}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
waitForPortRelease,
|
||||||
|
checkPortAvailability,
|
||||||
|
ensurePortFree
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const pathModule = require('path');
|
||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const ca = require('../../security/certificate_authority');
|
||||||
|
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
|
||||||
|
const { logDebug, logError } = require('../../infrastructure/logger');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
const certsDir = process.env.CERTS_DIR || './certs';
|
||||||
|
|
||||||
|
async function handleCertsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/certs') {
|
||||||
|
try {
|
||||||
|
const certDomains = await fs.readdir(certsDir);
|
||||||
|
const filteredDomains = [];
|
||||||
|
for (const file of certDomains) {
|
||||||
|
if ((await fs.stat(pathModule.join(certsDir, file))).isDirectory()) {
|
||||||
|
filteredDomains.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(filteredDomains));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch certs: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch certs' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath.startsWith('/api/cert-details')) {
|
||||||
|
const domain = url.searchParams.get('domain');
|
||||||
|
try {
|
||||||
|
const certPath = pathModule.join(certsDir, domain, 'cert.pem');
|
||||||
|
const certContent = await fs.readFile(certPath, 'utf8');
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end(certContent);
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch cert details: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end('Failed to fetch cert details');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/regenerate-ca') {
|
||||||
|
try {
|
||||||
|
ca.regenerateRootCA();
|
||||||
|
broadcast({ type: 'update-certs' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to regenerate CA: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/install-ca') {
|
||||||
|
try {
|
||||||
|
ca.installRootCA();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to install CA: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/generate-cert') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
|
||||||
|
if (!state.domainToIPMap.has(data.domain)) {
|
||||||
|
await createInterfaceForDomain(data.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = state.domainToIPMap.get(data.domain);
|
||||||
|
ca.getOrCreateDomainCert(data.domain, ip);
|
||||||
|
|
||||||
|
broadcast({ type: 'update-certs' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to generate cert: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/delete-cert') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const domainDir = pathModule.join(certsDir, data.domain);
|
||||||
|
await fs.rm(domainDir, { recursive: true, force: true });
|
||||||
|
broadcast({ type: 'update-certs' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to delete cert: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/regenerate-cert') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const domainDir = pathModule.join(certsDir, data.domain);
|
||||||
|
await fs.rm(domainDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
if (!state.domainToIPMap.has(data.domain)) {
|
||||||
|
await createInterfaceForDomain(data.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${data.domain}: ${state.domainToIPMap.get(data.domain)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = state.domainToIPMap.get(data.domain);
|
||||||
|
ca.getOrCreateDomainCert(data.domain, ip);
|
||||||
|
|
||||||
|
broadcast({ type: 'update-certs' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to regenerate cert: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleCertsRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const { getAllEntries, getHashForDomain, doAutoVotes, getConsensusState } = require('../../core/core');
|
||||||
|
const { addDomain } = require('../../core/domains');
|
||||||
|
const { validateDomainAddition, validateDomainRemoval } = require('../../infrastructure/validation');
|
||||||
|
const { atomicDomainCleanup } = require('../../core/domain_cleanup');
|
||||||
|
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
|
||||||
|
const { logDebug, logError } = require('../../infrastructure/logger');
|
||||||
|
const { trackRequest } = require('../../maintenance/metrics');
|
||||||
|
const { createErrorResponse } = require('../../infrastructure/error_handler');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
const { getPersistentPublicKey } = require('../../infrastructure/utils');
|
||||||
|
|
||||||
|
const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json';
|
||||||
|
|
||||||
|
async function handleDomainsRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/resolved-domains') {
|
||||||
|
try {
|
||||||
|
const allEntries = await getAllEntries();
|
||||||
|
const domainClaimants = new Map();
|
||||||
|
for (const entry of allEntries) {
|
||||||
|
if (entry.key.startsWith('claim:')) {
|
||||||
|
const parts = entry.key.split(':');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const domain = parts[1];
|
||||||
|
const claimant = parts[2];
|
||||||
|
if (!domainClaimants.has(domain)) domainClaimants.set(domain, new Set());
|
||||||
|
domainClaimants.get(domain).add(claimant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const localWriter = getPersistentPublicKey();
|
||||||
|
const domains = new Set(domainClaimants.keys());
|
||||||
|
const resolved = [];
|
||||||
|
for (const domain of domains) {
|
||||||
|
const hash = await getHashForDomain(domain) || 'none';
|
||||||
|
const isLocal = localWriter ? domainClaimants.get(domain)?.has(localWriter) || false : false;
|
||||||
|
resolved.push({ domain, hash, isLocal });
|
||||||
|
}
|
||||||
|
let internalDomains = ['p2ns.admin'];
|
||||||
|
try {
|
||||||
|
const { getInternalDomains } = require('../../plugins/plugin-handler');
|
||||||
|
internalDomains = await getInternalDomains();
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback if plugin system not available
|
||||||
|
}
|
||||||
|
for (const d of internalDomains) {
|
||||||
|
resolved.push({ domain: d, hash: 'internal', isLocal: true });
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(resolved.sort((a, b) => a.domain.localeCompare(b.domain))));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch resolved domains: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch domains' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/add-domain') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const validation = validateDomainAddition(data);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end(validation.error || 'Invalid input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract SSL flag - handle both boolean true and string "true"
|
||||||
|
const ssl = data.ssl === true || data.ssl === 'true' || data.ssl === 1;
|
||||||
|
await addDomain(validation.domain, validation.hash, ssl);
|
||||||
|
await doAutoVotes();
|
||||||
|
let domains = [];
|
||||||
|
if (await fs.access(domainsFile).then(() => true).catch(() => false)) {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8'));
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
logError('Admin', `Domains file does not contain an array, resetting to empty array`);
|
||||||
|
domains = [];
|
||||||
|
} else {
|
||||||
|
domains = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existingIndex = domains.findIndex(d => d.domain === validation.domain);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
domains[existingIndex].hash = validation.hash;
|
||||||
|
domains[existingIndex].ssl = ssl; // Update SSL flag
|
||||||
|
} else {
|
||||||
|
domains.push({ domain: validation.domain, hash: validation.hash, ssl: ssl });
|
||||||
|
}
|
||||||
|
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
|
||||||
|
if (!state.domainToIPMap.has(validation.domain)) {
|
||||||
|
await createInterfaceForDomain(validation.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${validation.domain}: ${state.domainToIPMap.get(validation.domain)}`);
|
||||||
|
}
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
trackRequest('/api/add-domain', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/remove-domain') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const validation = validateDomainRemoval(data);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end(validation.error || 'Invalid input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const domain = validation.domain;
|
||||||
|
|
||||||
|
// Authorization check: verify peer has claim and is resolved claimant
|
||||||
|
const localWriter = getPersistentPublicKey();
|
||||||
|
if (!localWriter) {
|
||||||
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Peer not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if peer has a claim for this domain
|
||||||
|
const allEntries = await getAllEntries();
|
||||||
|
const claimKey = `claim:${domain}:${localWriter}`;
|
||||||
|
const hasClaim = allEntries.some(entry => entry.key === claimKey);
|
||||||
|
|
||||||
|
if (!hasClaim) {
|
||||||
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Only domains you have claims and resolutions for can be deleted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if peer is the resolved claimant
|
||||||
|
const consensusState = await getConsensusState(domain);
|
||||||
|
if (consensusState.resolvedClaimant !== localWriter) {
|
||||||
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Only domains you have claims and resolutions for can be deleted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await atomicDomainCleanup(domain);
|
||||||
|
if (state.sendRemovalRequest) {
|
||||||
|
state.sendRemovalRequest(domain);
|
||||||
|
}
|
||||||
|
trackRequest('/api/remove-domain', true);
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
broadcast({ type: 'update-local-dns' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
trackRequest('/api/remove-domain', false);
|
||||||
|
const errorResponse = createErrorResponse(err, 500);
|
||||||
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||||
|
res.end(errorResponse.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleDomainsRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const { getAllEntries, removeAllRecords } = require('../../core/core');
|
||||||
|
const { logError, logInfo } = require('../../infrastructure/logger');
|
||||||
|
const { trackRequest } = require('../../maintenance/metrics');
|
||||||
|
|
||||||
|
// Get broadcast function if available
|
||||||
|
let broadcast;
|
||||||
|
try {
|
||||||
|
broadcast = require('../admin-backend/websocket').broadcast;
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback if websocket module not available
|
||||||
|
broadcast = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEntriesRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/entries') {
|
||||||
|
try {
|
||||||
|
const entries = await getAllEntries();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(entries));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch entries: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch entries' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/remove-all-records') {
|
||||||
|
try {
|
||||||
|
const result = await removeAllRecords();
|
||||||
|
trackRequest('/api/remove-all-records', true);
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `Removed ${result.removed} records from the network`,
|
||||||
|
removed: result.removed,
|
||||||
|
errors: result.errors
|
||||||
|
}));
|
||||||
|
logInfo('Admin', `Removed all records: ${result.removed} removed, ${result.errors} errors`);
|
||||||
|
} catch (err) {
|
||||||
|
trackRequest('/api/remove-all-records', false);
|
||||||
|
logError('Admin', `Failed to remove all records: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to remove all records', message: err.message }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleEntriesRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const dgram = require('dgram');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const { addDomain } = require('../../core/domains');
|
||||||
|
const { validateHolesailClient } = require('../../infrastructure/validation');
|
||||||
|
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
|
||||||
|
const { logDebug, logError, logInfo, logWarn } = require('../../infrastructure/logger');
|
||||||
|
const { startHolesailServer, saveHolesailServers } = require('../holesail-servers');
|
||||||
|
const { startForkedHolesailClient, saveHolesailClients } = require('../holesail-clients');
|
||||||
|
const { ensurePortFree } = require('../port-management');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
const domainsFile = process.env.DOMAINS_FILE || './cache/domains.json';
|
||||||
|
|
||||||
|
async function handleHolesailRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/holesail-servers') {
|
||||||
|
try {
|
||||||
|
const servers = Array.from(state.holesailOpts.entries()).map(([id, opts]) => {
|
||||||
|
const child = state.holesailChildren.get(id);
|
||||||
|
const info = state.holesailInfos.get(id) || {};
|
||||||
|
const status = child && !child.killed ? 'running' : 'stopped';
|
||||||
|
return { id, opts, info: { ...info, state: status } };
|
||||||
|
});
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(servers));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch Holesail servers: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch Holesail servers' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/holesail-clients') {
|
||||||
|
try {
|
||||||
|
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
||||||
|
const child = state.holesailClientChildren.get(id);
|
||||||
|
const info = state.holesailClientInfos.get(id) || {};
|
||||||
|
const key = `${opts.domain}:${opts.port}`;
|
||||||
|
const isHolesailActive = state.holesails.has(key);
|
||||||
|
const isChildRunning = child && !child.killed;
|
||||||
|
let status = 'stopped';
|
||||||
|
if (isChildRunning && isHolesailActive && info.state !== 'error') {
|
||||||
|
status = 'running';
|
||||||
|
} else if (isChildRunning || isHolesailActive) {
|
||||||
|
status = 'starting';
|
||||||
|
} else if (info.state === 'error') {
|
||||||
|
status = 'error';
|
||||||
|
}
|
||||||
|
return { id, opts, info: { ...info, state: status } };
|
||||||
|
});
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(clients));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch Holesail clients: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch Holesail clients' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-create') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const opts = { ...data };
|
||||||
|
const domain = opts.domain;
|
||||||
|
delete opts.domain;
|
||||||
|
const id = crypto.randomBytes(16).toString('hex');
|
||||||
|
logDebug('Admin', `Creating Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`);
|
||||||
|
const { id: createdId, info } = await startHolesailServer(id, opts);
|
||||||
|
if (domain) {
|
||||||
|
const hash = info.url;
|
||||||
|
await addDomain(domain, hash);
|
||||||
|
if (!state.domainToIPMap.has(domain)) {
|
||||||
|
await createInterfaceForDomain(domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${domain}: ${state.domainToIPMap.get(domain)}`);
|
||||||
|
}
|
||||||
|
let domains = [];
|
||||||
|
if (await fs.access(domainsFile).then(() => true).catch(() => false)) {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8'));
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
logError('Admin', `Domains file does not contain an array, resetting to empty array`);
|
||||||
|
domains = [];
|
||||||
|
} else {
|
||||||
|
domains = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existingIndex = domains.findIndex(d => d.domain === domain);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
domains[existingIndex].hash = hash;
|
||||||
|
} else {
|
||||||
|
domains.push({ domain, hash });
|
||||||
|
}
|
||||||
|
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
|
||||||
|
logInfo('Admin', `Automatically added domain ${domain} with hash ${hash} to P2P network and domains.json`);
|
||||||
|
}
|
||||||
|
await saveHolesailServers();
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ id: createdId }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to create Holesail server: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-delete') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { id } = JSON.parse(body);
|
||||||
|
const child = state.holesailChildren.get(id);
|
||||||
|
const opts = state.holesailOpts.get(id);
|
||||||
|
if (child) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await new Promise(resolve => {
|
||||||
|
child.on('exit', () => resolve());
|
||||||
|
setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
logWarn('Admin', `Forced SIGKILL for Holesail server child ${id}`);
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
state.holesailChildren.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
logInfo('Admin', `Closed Holesail server child process ${id}`);
|
||||||
|
}
|
||||||
|
state.holesailOpts.delete(id);
|
||||||
|
state.holesailInfos.delete(id);
|
||||||
|
await saveHolesailServers();
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to delete Holesail server: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-restart') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { id } = JSON.parse(body);
|
||||||
|
const child = state.holesailChildren.get(id);
|
||||||
|
const opts = state.holesailOpts.get(id);
|
||||||
|
if (!opts) {
|
||||||
|
throw new Error('Server not found');
|
||||||
|
}
|
||||||
|
let exitPromise;
|
||||||
|
if (child) {
|
||||||
|
logDebug('Admin', `Terminating existing Holesail server child process ${id}`);
|
||||||
|
exitPromise = new Promise((resolve) => {
|
||||||
|
child.once('exit', resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
logWarn('Admin', `Forced SIGKILL for Holesail server child ${id}`);
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await exitPromise;
|
||||||
|
logInfo('Admin', `Closed Holesail server child process ${id}`);
|
||||||
|
}
|
||||||
|
state.holesailInfos.delete(id);
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
logDebug('Admin', `Restarting Holesail server ${id} on ${opts.host || '0.0.0.0'}:${opts.port} without port check`);
|
||||||
|
const { id: createdId, info } = await startHolesailServer(id, opts);
|
||||||
|
if (opts.domain) {
|
||||||
|
const hash = info.url.replace('hs://', '');
|
||||||
|
await addDomain(opts.domain, hash);
|
||||||
|
if (!state.domainToIPMap.has(opts.domain)) {
|
||||||
|
await createInterfaceForDomain(opts.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${opts.domain}: ${state.domainToIPMap.get(opts.domain)}`);
|
||||||
|
}
|
||||||
|
let domains = [];
|
||||||
|
if (await fs.access(domainsFile).then(() => true).catch(() => false)) {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(domainsFile, 'utf8'));
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
logError('Admin', `Domains file does not contain an array, resetting to empty array`);
|
||||||
|
domains = [];
|
||||||
|
} else {
|
||||||
|
domains = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existingIndex = domains.findIndex(d => d.domain === opts.domain);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
domains[existingIndex].hash = hash;
|
||||||
|
} else {
|
||||||
|
domains.push({ domain: opts.domain, hash });
|
||||||
|
}
|
||||||
|
await fs.writeFile(domainsFile, JSON.stringify(domains, null, 2));
|
||||||
|
logInfo('Admin', `Automatically added domain ${opts.domain} with hash ${hash} to P2P network and domains.json`);
|
||||||
|
}
|
||||||
|
await saveHolesailServers();
|
||||||
|
broadcast({ type: 'update-holesail' });
|
||||||
|
broadcast({ type: 'update-database' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to restart Holesail server: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-client-create') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
const validation = validateHolesailClient(data);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end(validation.error || 'Invalid input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { domain, key, port, protocol } = validation;
|
||||||
|
if (!state.domainToIPMap.has(domain)) {
|
||||||
|
await createInterfaceForDomain(domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${domain}: ${state.domainToIPMap.get(domain)}`);
|
||||||
|
}
|
||||||
|
const ip = state.domainToIPMap.get(domain);
|
||||||
|
const portFree = await ensurePortFree(ip, port);
|
||||||
|
if (!portFree) {
|
||||||
|
throw new Error(`Unable to ensure port ${port} free on ${ip}`);
|
||||||
|
}
|
||||||
|
const id = crypto.randomBytes(16).toString('hex');
|
||||||
|
state.holesailClientInfos.set(id, { state: 'starting' });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
await startForkedHolesailClient(id, { domain, key, port, protocol: protocol || 'tcp' });
|
||||||
|
state.holesailClientInfos.set(id, { ...state.holesailClientInfos.get(id), state: 'running' });
|
||||||
|
await saveHolesailClients();
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ id }));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to create Holesail client: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-client-delete') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { id } = JSON.parse(body);
|
||||||
|
const child = state.holesailClientChildren.get(id);
|
||||||
|
const opts = state.holesailClientOpts.get(id);
|
||||||
|
if (child) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await new Promise(resolve => {
|
||||||
|
child.on('exit', () => resolve());
|
||||||
|
setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
logWarn('Admin', `Forced SIGKILL for Holesail client child ${id}`);
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
state.holesailClientChildren.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
logInfo('Admin', `Closed Holesail client ${id} for ${opts.domain}:${opts.port}`);
|
||||||
|
}
|
||||||
|
if (opts) {
|
||||||
|
const key = `${opts.domain}:${opts.port}`;
|
||||||
|
const holesail = state.holesails.get(key);
|
||||||
|
if (holesail) {
|
||||||
|
if (holesail instanceof dgram.Socket) {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
holesail.close(() => {
|
||||||
|
logInfo('Admin', `Closed UDP Holesail connection for ${key}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`);
|
||||||
|
holesail.close();
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await holesail.close();
|
||||||
|
logInfo('Admin', `Closed TCP Holesail connection for ${key}`);
|
||||||
|
}
|
||||||
|
state.holesails.delete(key);
|
||||||
|
if (state.holesailStartTimes) {
|
||||||
|
state.holesailStartTimes.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tlsServer = state.tlsServers.get(key);
|
||||||
|
if (tlsServer) {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
tlsServer.close(resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing TLS server for ${key}, forcing closure`);
|
||||||
|
tlsServer.destroy ? tlsServer.destroy() : tlsServer.close();
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
state.tlsServers.delete(key);
|
||||||
|
logInfo('Admin', `Closed TLS server for ${key}`);
|
||||||
|
}
|
||||||
|
const httpServer = state.httpServers.get(key);
|
||||||
|
if (httpServer) {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
httpServer.close(resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing HTTP server for ${key}, forcing closure`);
|
||||||
|
httpServer.destroy ? httpServer.destroy() : httpServer.close();
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
state.httpServers.delete(key);
|
||||||
|
logInfo('Admin', `Closed HTTP server for ${key}`);
|
||||||
|
}
|
||||||
|
const ip = state.domainToIPMap.get(opts.domain);
|
||||||
|
if (ip && opts.port) {
|
||||||
|
const freed = await ensurePortFree(ip, opts.port);
|
||||||
|
if (!freed) {
|
||||||
|
logError('Admin', `Failed to ensure port ${opts.port} free on ${ip} for ${key}`);
|
||||||
|
} else {
|
||||||
|
logInfo('Admin', `Successfully ensured port ${opts.port} free on ${ip} for ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.holesailClientOpts.delete(id);
|
||||||
|
state.holesailClientInfos.delete(id);
|
||||||
|
await saveHolesailClients();
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to delete Holesail client: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/holesail-client-restart') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => { body += chunk; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { id } = JSON.parse(body);
|
||||||
|
logDebug('Admin', `Initiating restart for Holesail client ${id}`);
|
||||||
|
const child = state.holesailClientChildren.get(id);
|
||||||
|
const opts = state.holesailClientOpts.get(id);
|
||||||
|
if (!opts) {
|
||||||
|
throw new Error(`Client ${id} not found`);
|
||||||
|
}
|
||||||
|
const key = `${opts.domain}:${opts.port}`;
|
||||||
|
let exitPromise;
|
||||||
|
if (child) {
|
||||||
|
logDebug('Admin', `Terminating existing child process for client ${id}`);
|
||||||
|
exitPromise = new Promise((resolve) => {
|
||||||
|
child.once('exit', resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
logWarn('Admin', `Forced SIGKILL for Holesail client child ${id}`);
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await exitPromise;
|
||||||
|
state.holesailClientChildren.delete(id);
|
||||||
|
state.holesailChildStartTimes.delete(id);
|
||||||
|
logInfo('Admin', `Closed Holesail client child process ${id}`);
|
||||||
|
}
|
||||||
|
const holesail = state.holesails.get(key);
|
||||||
|
if (holesail) {
|
||||||
|
logDebug('Admin', `Closing Holesail connection for ${key}`);
|
||||||
|
if (holesail instanceof dgram.Socket) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
holesail.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
logWarn('Admin', `Error closing UDP Holesail for ${key}: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
logInfo('Admin', `Closed UDP Holesail connection for ${key}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing UDP Holesail for ${key}, forcing closure`);
|
||||||
|
try {
|
||||||
|
holesail.close();
|
||||||
|
resolve();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await holesail.close();
|
||||||
|
logInfo('Admin', `Closed TCP Holesail connection for ${key}`);
|
||||||
|
}
|
||||||
|
state.holesails.delete(key);
|
||||||
|
if (state.holesailStartTimes) {
|
||||||
|
state.holesailStartTimes.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tlsServer = state.tlsServers.get(key);
|
||||||
|
if (tlsServer) {
|
||||||
|
logDebug('Admin', `Closing TLS server for ${key}`);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
tlsServer.close(resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing TLS server for ${key}, forcing closure`);
|
||||||
|
tlsServer.destroy ? tlsServer.destroy() : tlsServer.close();
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
state.tlsServers.delete(key);
|
||||||
|
logInfo('Admin', `Closed TLS server for ${key}`);
|
||||||
|
}
|
||||||
|
const httpServer = state.httpServers.get(key);
|
||||||
|
if (httpServer) {
|
||||||
|
logDebug('Admin', `Closing HTTP server for ${key}`);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
httpServer.close(resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
logWarn('Admin', `Timeout closing HTTP server for ${key}, forcing closure`);
|
||||||
|
httpServer.destroy ? httpServer.destroy() : httpServer.close();
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
state.httpServers.delete(key);
|
||||||
|
logInfo('Admin', `Closed HTTP server for ${key}`);
|
||||||
|
}
|
||||||
|
state.holesailClientInfos.set(id, { state: 'starting' });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
logInfo('Admin', `Holesail client ${id} stopped, preparing to restart`);
|
||||||
|
if (!state.domainToIPMap.has(opts.domain)) {
|
||||||
|
await createInterfaceForDomain(opts.domain);
|
||||||
|
logDebug('Admin', `Assigned IP to ${opts.domain}: ${state.domainToIPMap.get(opts.domain)}`);
|
||||||
|
}
|
||||||
|
const ip = state.domainToIPMap.get(opts.domain);
|
||||||
|
const portFree = await ensurePortFree(ip, opts.port);
|
||||||
|
if (!portFree) {
|
||||||
|
state.holesailClientInfos.set(id, { state: 'error', error: `Unable to free port ${opts.port} on ${ip}` });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
throw new Error(`Unable to ensure port ${opts.port} free on ${ip}`);
|
||||||
|
}
|
||||||
|
await startForkedHolesailClient(id, opts);
|
||||||
|
logInfo('Admin', `Successfully restarted Holesail client ${id} for ${opts.domain}:${opts.port}`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
const isHolesailActive = state.holesails.has(key);
|
||||||
|
if (!isHolesailActive) {
|
||||||
|
logWarn('Admin', `Holesail client ${id} for ${key} started but not active in state.holesails. Attempting final restart.`);
|
||||||
|
state.holesailClientInfos.set(id, { state: 'starting' });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
await startForkedHolesailClient(id, opts);
|
||||||
|
}
|
||||||
|
const finalCheck = state.holesails.has(key);
|
||||||
|
if (!finalCheck) {
|
||||||
|
state.holesailClientInfos.set(id, { state: 'error', error: `Failed to start after final attempt` });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
throw new Error(`Holesail client ${id} for ${key} failed to start after final attempt`);
|
||||||
|
}
|
||||||
|
state.holesailClientInfos.set(id, { ...state.holesailClientInfos.get(id), state: 'running' });
|
||||||
|
logDebug('Admin', `Verified Holesail client ${id} is active for ${key}`);
|
||||||
|
await saveHolesailClients();
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
state.holesailClientInfos.set(id, { state: 'error', error: err.message });
|
||||||
|
broadcast({ type: 'update-holesail-clients' });
|
||||||
|
logError('Admin', `Failed to restart Holesail client: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleHolesailRoutes };
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const { checkRateLimit } = require('../../infrastructure/rate_limit');
|
||||||
|
const { trackRequest } = require('../../maintenance/metrics');
|
||||||
|
const { handleStaticRoutes } = require('./static');
|
||||||
|
const { handleDomainsRoutes } = require('./domains');
|
||||||
|
const { handleEntriesRoutes } = require('./entries');
|
||||||
|
const { handlePeersRoutes } = require('./peers');
|
||||||
|
const { handleCertsRoutes } = require('./certs');
|
||||||
|
const { handleInterfacesRoutes } = require('./interfaces');
|
||||||
|
const { handleLocalDnsRoutes } = require('./local-dns');
|
||||||
|
const { handleStatusRoutes } = require('./status');
|
||||||
|
const { handleStatsRoutes } = require('./stats');
|
||||||
|
const { handleHolesailRoutes } = require('./holesail');
|
||||||
|
const { handleSettingsRoutes } = require('./settings');
|
||||||
|
|
||||||
|
async function handleAdminRequest(req, res) {
|
||||||
|
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||||
|
const urlPath = url.pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
// Check rate limit for API endpoints (GET requests and local IPs are exempt)
|
||||||
|
// Only rate limit POST requests, GET requests are safe and expected to be frequent
|
||||||
|
if (urlPath.startsWith('/api/') && method === 'POST') {
|
||||||
|
const rateLimitError = checkRateLimit(req);
|
||||||
|
if (rateLimitError) {
|
||||||
|
res.writeHead(rateLimitError.statusCode, rateLimitError.headers);
|
||||||
|
res.end(rateLimitError.body);
|
||||||
|
trackRequest(urlPath, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach urlPath to req for route handlers
|
||||||
|
req.urlPath = urlPath;
|
||||||
|
|
||||||
|
// Try each route handler in order
|
||||||
|
if (await handleStaticRoutes(req, res)) return;
|
||||||
|
if (await handleDomainsRoutes(req, res)) return;
|
||||||
|
if (await handleEntriesRoutes(req, res)) return;
|
||||||
|
if (await handlePeersRoutes(req, res)) return;
|
||||||
|
if (await handleCertsRoutes(req, res)) return;
|
||||||
|
if (await handleInterfacesRoutes(req, res)) return;
|
||||||
|
if (await handleLocalDnsRoutes(req, res)) return;
|
||||||
|
if (await handleStatusRoutes(req, res)) return;
|
||||||
|
if (await handleStatsRoutes(req, res)) return;
|
||||||
|
if (await handleHolesailRoutes(req, res)) return;
|
||||||
|
if (await handleSettingsRoutes(req, res)) return;
|
||||||
|
|
||||||
|
// No route matched
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not Found');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleAdminRequest };
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const state = require('../../infrastructure/state');
|
||||||
|
const { cleanupInterfaces } = require('../../maintenance/cleanup');
|
||||||
|
const { logError } = require('../../infrastructure/logger');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
async function handleInterfacesRoutes(req, res) {
|
||||||
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'GET' && urlPath === '/api/interfaces') {
|
||||||
|
try {
|
||||||
|
const interfaces = Array.from(state.domainToIPMap.entries()).map(([domain, ip]) => ({ domain, ip }));
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(interfaces));
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: 'Failed to fetch interfaces' }));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && urlPath === '/api/cleanup-interfaces') {
|
||||||
|
try {
|
||||||
|
await cleanupInterfaces();
|
||||||
|
broadcast({ type: 'update-interfaces' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('OK');
|
||||||
|
} catch (err) {
|
||||||
|
logError('Admin', `Failed to cleanup interfaces: ${err.message}`);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(err.message);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleInterfacesRoutes };
|
||||||
|
|
||||||