This commit is contained in:
Raven Scott
2025-12-17 20:05:50 -05:00
commit 742e27d3f7
276 changed files with 89838 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+13
View File
@@ -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
+314
View File
@@ -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.
[![screenshot](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/domains-tab.png)](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)
[![screenshot](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/p2p-domain-cert-dot-decode.png)](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.
[![screenshot](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/internal-domain-example-dot-plugin.png)](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
+115
View File
@@ -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
+491
View File
@@ -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
+108
View File
@@ -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 |
+414
View File
@@ -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
+736
View File
@@ -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);
```
+165
View File
@@ -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
+427
View File
@@ -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
+1364
View File
File diff suppressed because it is too large Load Diff
+972
View File
@@ -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.
[![screenshot](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/domains-tab.png)](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)
[![screenshot](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/p2p-domain-cert-dot-decode.png)](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.
[![screenshot](https://git.ssh.surf/snxraven/p2ns/raw/branch/main/images/internal-domain-example-dot-plugin.png)](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.
+1742
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -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.
+424
View File
@@ -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.**
+643
View File
@@ -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
+645
View File
@@ -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
+483
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+247
View File
@@ -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
+239
View File
@@ -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
+66
View File
@@ -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)
+529
View File
@@ -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
+133
View File
@@ -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
+470
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

BIN
View File
Binary file not shown.
+57
View File
@@ -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 };
+196
View File
@@ -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 };
+474
View File
@@ -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
};
+138
View File
@@ -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
};
+230
View File
@@ -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);
}
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+331
View File
@@ -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;
+146
View File
@@ -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;
+424
View File
@@ -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
});
};
+339
View File
@@ -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;
+125
View File
@@ -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;
+44
View File
@@ -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;
+191
View File
@@ -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;
+838
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+39
View File
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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;
+98
View File
@@ -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;
+300
View File
@@ -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();
}
+96
View File
@@ -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);
}
});
+68
View File
@@ -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
};
+120
View File
@@ -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
};
+107
View File
@@ -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
};
+800
View File
@@ -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">&times;</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">&times;</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>
+136
View File
@@ -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
};
+157
View File
@@ -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 };
+181
View File
@@ -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 };
+57
View File
@@ -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 };
+505
View File
@@ -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 };
+54
View File
@@ -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 };
+41
View File
@@ -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 };

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