diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..74f323d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,89 @@ +#!/bin/bash + +# Colors for output +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NOCOLOR='\033[0m' + +# Get the directory where this hook is located +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Go up from .git/hooks/ to repo root +REPO_ROOT="$(cd "$HOOK_DIR/../.." && pwd)" +CHANGELOG_SCRIPT="$REPO_ROOT/scripts/update_changelog.sh" +PREVIEW_FILE="$REPO_ROOT/.changelog_preview.tmp" +VERSION_FILE="$REPO_ROOT/.changelog_version.tmp" + +# Only run changelog update if there are actual code changes (not just changelog files) +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) +if [ -z "$STAGED_FILES" ]; then + # No staged files, exit + exit 0 +fi + +# Check if only CHANGELOG.md and/or Makefile are being committed +OTHER_FILES=$(echo "$STAGED_FILES" | grep -v "^CHANGELOG.md$" | grep -v "^Makefile$") +if [ -z "$OTHER_FILES" ]; then + # Only changelog files are being committed, skip update + exit 0 +fi + +# Update changelog before commit +if [ -f "$CHANGELOG_SCRIPT" ]; then + echo -e "\n${CYAN}Updating changelog...${NOCOLOR}" + + # Set environment variable to indicate we're running from pre-commit + export CHANGELOG_CONTEXT=pre-commit + + bash "$CHANGELOG_SCRIPT" + changelog_status=$? + if [ $changelog_status -ne 0 ]; then + echo -e "${RED}Commit aborted: changelog update failed.${NOCOLOR}" + exit 1 + fi + + # Show preview if changelog was updated + if [ -f "$PREVIEW_FILE" ] && [ -f "$VERSION_FILE" ]; then + NEW_VERSION=$(cat "$VERSION_FILE") + PREVIEW_CONTENT=$(cat "$PREVIEW_FILE") + + echo "" + echo -e "${BLUE}========================================================================${NOCOLOR}" + echo -e "${CYAN} CHANGELOG PREVIEW${NOCOLOR}" + echo -e "${BLUE}========================================================================${NOCOLOR}" + echo "" + echo -e "${GREEN}New Version: ${YELLOW}$NEW_VERSION${NOCOLOR}" + echo "" + echo -e "${CYAN}Changelog Entry:${NOCOLOR}" + echo -e "${BLUE}────────────────────────────────────────────────────────────────────────${NOCOLOR}" + echo -e "$PREVIEW_CONTENT" + echo -e "${BLUE}────────────────────────────────────────────────────────────────────────${NOCOLOR}" + echo "" + echo -e "${YELLOW}Do you want to proceed with the commit? (yes/no):${NOCOLOR} " + # Read from /dev/tty to ensure we can read from terminal even in git hook context + read -r confirmation < /dev/tty + + if [ "$confirmation" != "yes" ]; then + echo -e "${RED}Commit aborted by user.${NOCOLOR}" + echo -e "${YELLOW}To revert changes, run:${NOCOLOR}" + echo -e " git checkout CHANGELOG.md Makefile" + # Clean up temp files + rm -f "$PREVIEW_FILE" "$VERSION_FILE" + exit 1 + fi + + echo -e "${GREEN}Proceeding with commit...${NOCOLOR}" + + # Add the updated CHANGELOG.md and Makefile to the current commit + echo -e "${CYAN}Staging CHANGELOG.md and Makefile...${NOCOLOR}" + git add CHANGELOG.md Makefile + + # Clean up temp files + rm -f "$PREVIEW_FILE" "$VERSION_FILE" + fi +else + echo -e "${YELLOW}Warning: changelog update script not found at $CHANGELOG_SCRIPT${NOCOLOR}" +fi + diff --git a/.githooks/pre-push b/.githooks/pre-push index 5718cb0..b340af6 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,11 +1,18 @@ #!/bin/bash -echo -e "\nRunning tests:" +# Colors for output +CYAN='\033[0;36m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NOCOLOR='\033[0m' + +# Run tests before push +echo -e "\n${CYAN}Running tests...${NOCOLOR}" go test ./... # Runs all tests in your repo status=$? if [ $status -ne 0 ]; then - echo "Push aborted: some tests failed." + echo -e "${RED}Push aborted: some tests failed.${NOCOLOR}" exit 1 else - echo "All tests passed. Proceeding with push." + echo -e "${GREEN}All tests passed. Proceeding with push.${NOCOLOR}" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index bc44467..62ed13e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,144 @@ The format is based on [Keep a Changelog][keepachangelog] and adheres to [Semant ### Deprecated ### Fixed +## [0.53.18] - 2025-11-03 + +### Added +\n +### Changed +- Increased the connection timeout during peer discovery from 15 seconds to 20 seconds to improve connection reliability. +- Removed unnecessary debug logging related to filtering out ephemeral port addresses during peer exchange. + +### Deprecated + +### Removed + +### Fixed +\n +## [0.53.17] - 2025-11-03 + +### Added +- Added a new Git `pre-commit` hook to automatically update the changelog and version before committing, ensuring version consistency. + +### Changed +- Refactored the `update_changelog.sh` script to support different execution contexts (pre-commit vs. pre-push), allowing it to analyze only staged changes during commit. +- The Git `pre-push` hook was simplified by removing the changelog update logic, which is now handled by the `pre-commit` hook. + +### Deprecated + +### Removed + +### Fixed +\n +## [0.53.16] - 2025-11-03 + +### Added +\n +### Changed +- Improved the changelog generation script to prevent infinite loops when the only unpushed commit is a previous changelog update. + +### Deprecated + +### Removed + +### Fixed +\n +## [0.53.15] - 2025-11-03 + +### Added +\n +### Changed +- Improved the pre-push git hook to automatically commit updated changelog and Makefile after generation. +- Updated the changelog generation script to load the OpenRouter API key from the .env file or environment variables for better security. +- Modified the pre-push hook to read user confirmation from /dev/tty for better compatibility. +- Updated the bootstrap peer logic to prioritize the DEBROS_BOOTSTRAP_PEERS environment variable for easier configuration. +- Improved the gateway's private host check to correctly handle IPv6 addresses with or without brackets and ports. + +### Deprecated + +### Removed + +### Fixed +\n +## [0.53.15] - 2025-11-03 + +### Added +\n +### Changed +- Improved the pre-push git hook to automatically commit updated changelog and Makefile after generation. +- Updated the changelog generation script to load the OpenRouter API key from the .env file or environment variables for better security. +- Modified the pre-push hook to read user confirmation from /dev/tty for better compatibility. +- Updated the bootstrap peer logic to prioritize the DEBROS_BOOTSTRAP_PEERS environment variable for easier configuration. +- Improved the gateway's private host check to correctly handle IPv6 addresses with or without brackets and ports. + +### Deprecated + +### Removed + +### Fixed +\n +## [0.53.14] - 2025-11-03 + +### Added +- Added a new `install-hooks` target to the Makefile to easily set up git hooks. +- Added a script (`scripts/install-hooks.sh`) to copy git hooks from `.githooks` to `.git/hooks`. + +### Changed +- Improved the pre-push git hook to automatically commit the updated `CHANGELOG.md` and `Makefile` after generating the changelog. +- Updated the changelog generation script (`scripts/update_changelog.sh`) to load the OpenRouter API key from the `.env` file or environment variables, improving security and configuration. +- Modified the pre-push hook to read user confirmation from `/dev/tty` for better compatibility in various terminal environments. +- Updated the bootstrap peer logic to check the `DEBROS_BOOTSTRAP_PEERS` environment variable first, allowing easier configuration override. +- Improved the gateway's private host check to correctly handle IPv6 addresses with or without brackets and ports. + +### Deprecated + +### Removed + +### Fixed +\n +## [0.53.14] - 2025-11-03 + +### Added +- Added a new `install-hooks` target to the Makefile to easily set up git hooks. +- Added a script (`scripts/install-hooks.sh`) to copy git hooks from `.githooks` to `.git/hooks`. + +### Changed +- Improved the pre-push git hook to automatically commit the updated `CHANGELOG.md` and `Makefile` after generating the changelog. +- Updated the changelog generation script (`scripts/update_changelog.sh`) to load the OpenRouter API key from the `.env` file or environment variables, improving security and configuration. +- Modified the pre-push hook to read user confirmation from `/dev/tty` for better compatibility in various terminal environments. + +### Deprecated + +### Removed + +### Fixed +\n + +## [0.53.8] - 2025-10-31 + +### Added + +- **HTTPS/ACME Support**: Gateway now supports automatic HTTPS with Let's Encrypt certificates via ACME + - Interactive domain configuration during `network-cli setup` command + - Automatic port availability checking for ports 80 and 443 before enabling HTTPS + - DNS resolution verification to ensure domain points to the server IP + - TLS certificate cache directory management (`~/.debros/tls-cache`) + - Gateway automatically serves HTTP (port 80) for ACME challenges and HTTPS (port 443) for traffic + - New gateway config fields: `enable_https`, `domain_name`, `tls_cache_dir` +- **Domain Validation**: Added domain name validation and DNS verification helpers in setup CLI +- **Port Checking**: Added port availability checking utilities to detect conflicts before HTTPS setup + +### Changed + +- Updated `generateGatewayConfigDirect` to include HTTPS configuration fields +- Enhanced gateway config parsing to support HTTPS settings with validation +- Modified gateway startup to handle both HTTP-only and HTTPS+ACME modes +- Gateway now automatically manages ACME certificate acquisition and renewal + +### Fixed + +- Improved error handling during HTTPS setup with clear messaging when ports are unavailable +- Enhanced DNS verification flow with better user feedback during setup ## [0.53.0] - 2025-10-31 diff --git a/Makefile b/Makefile index 2f9826c..22f1d5c 100644 --- a/Makefile +++ b/Makefile @@ -19,9 +19,9 @@ test-e2e: # Network - Distributed P2P Database System # Makefile for development and build tasks -.PHONY: build clean test run-node run-node2 run-node3 run-example deps tidy fmt vet lint clear-ports +.PHONY: build clean test run-node run-node2 run-node3 run-example deps tidy fmt vet lint clear-ports install-hooks -VERSION := 0.53.3 +VERSION := 0.53.18 COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)' @@ -37,6 +37,11 @@ build: deps go build -ldflags "$(LDFLAGS) -X 'github.com/DeBrosOfficial/network/pkg/gateway.BuildVersion=$(VERSION)' -X 'github.com/DeBrosOfficial/network/pkg/gateway.BuildCommit=$(COMMIT)' -X 'github.com/DeBrosOfficial/network/pkg/gateway.BuildTime=$(DATE)'" -o bin/gateway ./cmd/gateway @echo "Build complete! Run ./bin/network-cli version" +# Install git hooks +install-hooks: + @echo "Installing git hooks..." + @bash scripts/install-hooks.sh + # Clean build artifacts clean: @echo "Cleaning build artifacts..." diff --git a/cmd/cli/main.go b/cmd/cli/main.go index e191e64..e396013 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -108,6 +108,10 @@ func main() { } cli.HandleConnectCommand(args[0], timeout) + // RQLite commands + case "rqlite": + cli.HandleRQLiteCommand(args) + // Help case "help", "--help", "-h": showHelp() @@ -175,6 +179,9 @@ func showHelp() { fmt.Printf("🗄️ Database:\n") fmt.Printf(" query 🔐 Execute database query\n\n") + fmt.Printf("🔧 RQLite:\n") + fmt.Printf(" rqlite fix 🔧 Fix misconfigured join address and clean raft state\n\n") + fmt.Printf("📡 PubSub:\n") fmt.Printf(" pubsub publish 🔐 Publish message\n") fmt.Printf(" pubsub subscribe 🔐 Subscribe to topic\n") diff --git a/cmd/gateway/config.go b/cmd/gateway/config.go index 76548da..e10763c 100644 --- a/cmd/gateway/config.go +++ b/cmd/gateway/config.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "path/filepath" "strings" "github.com/DeBrosOfficial/network/pkg/config" @@ -53,6 +54,9 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config { ClientNamespace string `yaml:"client_namespace"` RQLiteDSN string `yaml:"rqlite_dsn"` BootstrapPeers []string `yaml:"bootstrap_peers"` + EnableHTTPS bool `yaml:"enable_https"` + DomainName string `yaml:"domain_name"` + TLSCacheDir string `yaml:"tls_cache_dir"` } data, err := os.ReadFile(configPath) @@ -79,6 +83,9 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config { ClientNamespace: "default", BootstrapPeers: nil, RQLiteDSN: "", + EnableHTTPS: false, + DomainName: "", + TLSCacheDir: "", } if v := strings.TrimSpace(y.ListenAddr); v != "" { @@ -103,6 +110,21 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config { } } + // HTTPS configuration + cfg.EnableHTTPS = y.EnableHTTPS + if v := strings.TrimSpace(y.DomainName); v != "" { + cfg.DomainName = v + } + if v := strings.TrimSpace(y.TLSCacheDir); v != "" { + cfg.TLSCacheDir = v + } else if cfg.EnableHTTPS { + // Default TLS cache directory if HTTPS is enabled but not specified + homeDir, err := os.UserHomeDir() + if err == nil { + cfg.TLSCacheDir = filepath.Join(homeDir, ".debros", "tls-cache") + } + } + // Validate configuration if errs := cfg.ValidateConfig(); len(errs) > 0 { fmt.Fprintf(os.Stderr, "\nGateway configuration errors (%d):\n", len(errs)) diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go index 02b9446..d700474 100644 --- a/cmd/gateway/main.go +++ b/cmd/gateway/main.go @@ -12,6 +12,7 @@ import ( "github.com/DeBrosOfficial/network/pkg/gateway" "github.com/DeBrosOfficial/network/pkg/logging" "go.uber.org/zap" + "golang.org/x/crypto/acme/autocert" ) func setupLogger() *logging.ColoredLogger { @@ -42,6 +43,123 @@ func main() { logger.ComponentInfo(logging.ComponentGeneral, "Creating HTTP server and routes...") + // Check if HTTPS is enabled + if cfg.EnableHTTPS && cfg.DomainName != "" { + logger.ComponentInfo(logging.ComponentGeneral, "HTTPS enabled with ACME", + zap.String("domain", cfg.DomainName), + zap.String("tls_cache_dir", cfg.TLSCacheDir), + ) + + // Set up ACME manager + manager := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(cfg.DomainName), + } + + // Set cache directory if specified + if cfg.TLSCacheDir != "" { + manager.Cache = autocert.DirCache(cfg.TLSCacheDir) + logger.ComponentInfo(logging.ComponentGeneral, "Using TLS certificate cache", + zap.String("cache_dir", cfg.TLSCacheDir), + ) + } + + // Create HTTP server for ACME challenge (port 80) + httpServer := &http.Server{ + Addr: ":80", + Handler: manager.HTTPHandler(nil), // Redirects all HTTP traffic to HTTPS except ACME challenge + } + + // Create HTTPS server (port 443) + httpsServer := &http.Server{ + Addr: ":443", + Handler: gw.Routes(), + TLSConfig: manager.TLSConfig(), + } + + // Start HTTP server for ACME challenge + logger.ComponentInfo(logging.ComponentGeneral, "Starting HTTP server for ACME challenge on port 80...") + httpLn, err := net.Listen("tcp", ":80") + if err != nil { + logger.ComponentError(logging.ComponentGeneral, "failed to bind HTTP listen address (port 80)", zap.Error(err)) + os.Exit(1) + } + logger.ComponentInfo(logging.ComponentGeneral, "HTTP listener bound", zap.String("listen_addr", httpLn.Addr().String())) + + // Start HTTPS server + logger.ComponentInfo(logging.ComponentGeneral, "Starting HTTPS server on port 443...") + httpsLn, err := net.Listen("tcp", ":443") + if err != nil { + logger.ComponentError(logging.ComponentGeneral, "failed to bind HTTPS listen address (port 443)", zap.Error(err)) + os.Exit(1) + } + logger.ComponentInfo(logging.ComponentGeneral, "HTTPS listener bound", zap.String("listen_addr", httpsLn.Addr().String())) + + // Serve HTTP in a goroutine + httpServeErrCh := make(chan error, 1) + go func() { + if err := httpServer.Serve(httpLn); err != nil && err != http.ErrServerClosed { + httpServeErrCh <- err + return + } + httpServeErrCh <- nil + }() + + // Serve HTTPS in a goroutine + httpsServeErrCh := make(chan error, 1) + go func() { + if err := httpsServer.ServeTLS(httpsLn, "", ""); err != nil && err != http.ErrServerClosed { + httpsServeErrCh <- err + return + } + httpsServeErrCh <- nil + }() + + // Wait for termination signal or server error + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + + select { + case sig := <-quit: + logger.ComponentInfo(logging.ComponentGeneral, "shutdown signal received", zap.String("signal", sig.String())) + case err := <-httpServeErrCh: + if err != nil { + logger.ComponentError(logging.ComponentGeneral, "HTTP server error", zap.Error(err)) + } else { + logger.ComponentInfo(logging.ComponentGeneral, "HTTP server exited normally") + } + case err := <-httpsServeErrCh: + if err != nil { + logger.ComponentError(logging.ComponentGeneral, "HTTPS server error", zap.Error(err)) + } else { + logger.ComponentInfo(logging.ComponentGeneral, "HTTPS server exited normally") + } + } + + logger.ComponentInfo(logging.ComponentGeneral, "Shutting down gateway servers...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Shutdown HTTPS server + if err := httpsServer.Shutdown(ctx); err != nil { + logger.ComponentError(logging.ComponentGeneral, "HTTPS server shutdown error", zap.Error(err)) + } else { + logger.ComponentInfo(logging.ComponentGeneral, "HTTPS server shutdown complete") + } + + // Shutdown HTTP server + if err := httpServer.Shutdown(ctx); err != nil { + logger.ComponentError(logging.ComponentGeneral, "HTTP server shutdown error", zap.Error(err)) + } else { + logger.ComponentInfo(logging.ComponentGeneral, "HTTP server shutdown complete") + } + + logger.ComponentInfo(logging.ComponentGeneral, "Gateway shutdown complete") + return + } + + // Standard HTTP server (no HTTPS) server := &http.Server{ Addr: cfg.ListenAddr, Handler: gw.Routes(), diff --git a/examples/basic_usage.go b/examples/basic_usage.go deleted file mode 100644 index e95e78d..0000000 --- a/examples/basic_usage.go +++ /dev/null @@ -1,151 +0,0 @@ -package main - -import ( - "context" - "log" - "time" - - "github.com/DeBrosOfficial/network/pkg/client" -) - -func main() { - // Create client configuration - config := client.DefaultClientConfig("example_app") - config.BootstrapPeers = []string{ - "/ip4/127.0.0.1/tcp/4001/p2p/QmBootstrap1", - } - - // Create network client - networkClient, err := client.NewClient(config) - if err != nil { - log.Fatalf("Failed to create network client: %v", err) - } - - // Connect to network - if err := networkClient.Connect(); err != nil { - log.Fatalf("Failed to connect to network: %v", err) - } - defer networkClient.Disconnect() - - log.Printf("Connected to network successfully!") - - // Example: Database operations - demonstrateDatabase(networkClient) - - // Example: Pub/Sub messaging - demonstratePubSub(networkClient) - - // Example: Network information - demonstrateNetworkInfo(networkClient) - - log.Printf("Example completed successfully!") -} - -func demonstrateDatabase(client client.NetworkClient) { - ctx := context.Background() - db := client.Database() - - log.Printf("=== Database Operations ===") - - // Create a table - schema := ` - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY, - content TEXT NOT NULL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP - ) - ` - if err := db.CreateTable(ctx, schema); err != nil { - log.Printf("Error creating table: %v", err) - return - } - log.Printf("Table created successfully") - - // Insert some data - insertSQL := "INSERT INTO messages (content) VALUES (?)" - result, err := db.Query(ctx, insertSQL, "Hello, distributed world!") - if err != nil { - log.Printf("Error inserting data: %v", err) - return - } - log.Printf("Data inserted, result: %+v", result) - - // Query data - selectSQL := "SELECT * FROM messages" - result, err = db.Query(ctx, selectSQL) - if err != nil { - log.Printf("Error querying data: %v", err) - return - } - log.Printf("Query result: %+v", result) -} - -func demonstratePubSub(client client.NetworkClient) { - ctx := context.Background() - pubsub := client.PubSub() - - log.Printf("=== Pub/Sub Operations ===") - - // Subscribe to a topic - topic := "notifications" - handler := func(topic string, data []byte) error { - log.Printf("Received message on topic '%s': %s", topic, string(data)) - return nil - } - - if err := pubsub.Subscribe(ctx, topic, handler); err != nil { - log.Printf("Error subscribing: %v", err) - return - } - log.Printf("Subscribed to topic: %s", topic) - - // Publish a message - message := []byte("Hello from pub/sub!") - if err := pubsub.Publish(ctx, topic, message); err != nil { - log.Printf("Error publishing: %v", err) - return - } - log.Printf("Message published") - - // Wait a bit for message delivery - time.Sleep(time.Millisecond * 100) - - // List topics - topics, err := pubsub.ListTopics(ctx) - if err != nil { - log.Printf("Error listing topics: %v", err) - return - } - log.Printf("Subscribed topics: %v", topics) -} - -func demonstrateNetworkInfo(client client.NetworkClient) { - ctx := context.Background() - network := client.Network() - - log.Printf("=== Network Information ===") - - // Get network status - status, err := network.GetStatus(ctx) - if err != nil { - log.Printf("Error getting status: %v", err) - return - } - log.Printf("Network status: %+v", status) - - // Get peers - peers, err := network.GetPeers(ctx) - if err != nil { - log.Printf("Error getting peers: %v", err) - return - } - log.Printf("Connected peers: %+v", peers) - - // Get client health - health, err := client.Health() - if err != nil { - log.Printf("Error getting health: %v", err) - return - } - log.Printf("Client health: %+v", health) -} diff --git a/examples/sdk-typescript/README.md b/examples/sdk-typescript/README.md deleted file mode 100644 index 1e78797..0000000 --- a/examples/sdk-typescript/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# DeBros Gateway TypeScript SDK (Minimal Example) - -Minimal, dependency-light wrapper around the HTTP Gateway. - -Usage: - -```bash -npm i -export GATEWAY_BASE_URL=http://127.0.0.1:6001 -export GATEWAY_API_KEY=your_api_key -``` - -```ts -import { GatewayClient } from './src/client'; - -const c = new GatewayClient(process.env.GATEWAY_BASE_URL!, process.env.GATEWAY_API_KEY!); -await c.createTable('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); -await c.transaction([ - 'INSERT INTO users (id,name) VALUES (1,\'Alice\')' -]); -const res = await c.query('SELECT name FROM users WHERE id = ?', [1]); -console.log(res.rows); -``` diff --git a/examples/sdk-typescript/package.json b/examples/sdk-typescript/package.json deleted file mode 100644 index 3aac5f0..0000000 --- a/examples/sdk-typescript/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "debros-gateway-sdk", - "version": "0.1.0", - "private": true, - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsc -p tsconfig.json" - }, - "dependencies": { - "isomorphic-ws": "^5.0.0" - }, - "devDependencies": { - "typescript": "^5.5.4" - } -} diff --git a/examples/sdk-typescript/src/client.ts b/examples/sdk-typescript/src/client.ts deleted file mode 100644 index bf80606..0000000 --- a/examples/sdk-typescript/src/client.ts +++ /dev/null @@ -1,154 +0,0 @@ -import WebSocket from "isomorphic-ws"; - -export class GatewayClient { - constructor( - private baseUrl: string, - private apiKey: string, - private http = fetch - ) {} - - private headers(json = true): Record { - const h: Record = { "X-API-Key": this.apiKey }; - if (json) h["Content-Type"] = "application/json"; - return h; - } - - // Database - async createTable(schema: string): Promise { - const r = await this.http(`${this.baseUrl}/v1/rqlite/create-table`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify({ schema }), - }); - if (!r.ok) throw new Error(`createTable failed: ${r.status}`); - } - - async dropTable(table: string): Promise { - const r = await this.http(`${this.baseUrl}/v1/rqlite/drop-table`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify({ table }), - }); - if (!r.ok) throw new Error(`dropTable failed: ${r.status}`); - } - - async query(sql: string, args: any[] = []): Promise<{ rows: T[] }> { - const r = await this.http(`${this.baseUrl}/v1/rqlite/query`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify({ sql, args }), - }); - if (!r.ok) throw new Error(`query failed: ${r.status}`); - return r.json(); - } - - async transaction(statements: string[]): Promise { - const r = await this.http(`${this.baseUrl}/v1/rqlite/transaction`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify({ statements }), - }); - if (!r.ok) throw new Error(`transaction failed: ${r.status}`); - } - - async schema(): Promise { - const r = await this.http(`${this.baseUrl}/v1/rqlite/schema`, { - headers: this.headers(false), - }); - if (!r.ok) throw new Error(`schema failed: ${r.status}`); - return r.json(); - } - - // Storage - async put(key: string, value: Uint8Array | string): Promise { - const body = - typeof value === "string" ? new TextEncoder().encode(value) : value; - const r = await this.http( - `${this.baseUrl}/v1/storage/put?key=${encodeURIComponent(key)}`, - { - method: "POST", - headers: { "X-API-Key": this.apiKey }, - body, - } - ); - if (!r.ok) throw new Error(`put failed: ${r.status}`); - } - - async get(key: string): Promise { - const r = await this.http( - `${this.baseUrl}/v1/storage/get?key=${encodeURIComponent(key)}`, - { - headers: { "X-API-Key": this.apiKey }, - } - ); - if (!r.ok) throw new Error(`get failed: ${r.status}`); - const buf = new Uint8Array(await r.arrayBuffer()); - return buf; - } - - async exists(key: string): Promise { - const r = await this.http( - `${this.baseUrl}/v1/storage/exists?key=${encodeURIComponent(key)}`, - { - headers: this.headers(false), - } - ); - if (!r.ok) throw new Error(`exists failed: ${r.status}`); - const j = await r.json(); - return !!j.exists; - } - - async list(prefix = ""): Promise { - const r = await this.http( - `${this.baseUrl}/v1/storage/list?prefix=${encodeURIComponent(prefix)}`, - { - headers: this.headers(false), - } - ); - if (!r.ok) throw new Error(`list failed: ${r.status}`); - const j = await r.json(); - return j.keys || []; - } - - async delete(key: string): Promise { - const r = await this.http(`${this.baseUrl}/v1/storage/delete`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify({ key }), - }); - if (!r.ok) throw new Error(`delete failed: ${r.status}`); - } - - // PubSub (minimal) - subscribe( - topic: string, - onMessage: (data: Uint8Array) => void - ): { close: () => void } { - const url = new URL(`${this.baseUrl.replace(/^http/, "ws")}/v1/pubsub/ws`); - url.searchParams.set("topic", topic); - const ws = new WebSocket(url.toString(), { - headers: { "X-API-Key": this.apiKey }, - } as any); - ws.binaryType = "arraybuffer"; - ws.onmessage = (ev: any) => { - const data = - ev.data instanceof ArrayBuffer - ? new Uint8Array(ev.data) - : new TextEncoder().encode(String(ev.data)); - onMessage(data); - }; - return { close: () => ws.close() }; - } - - async publish(topic: string, data: Uint8Array | string): Promise { - const bytes = - typeof data === "string" ? new TextEncoder().encode(data) : data; - const b64 = Buffer.from(bytes).toString("base64"); - const r = await this.http(`${this.baseUrl}/v1/pubsub/publish`, { - method: "POST", - headers: this.headers(), - body: JSON.stringify({ topic, data_base64: b64 }), - }); - if (!r.ok) throw new Error(`publish failed: ${r.status}`); - } -} diff --git a/examples/sdk-typescript/tsconfig.json b/examples/sdk-typescript/tsconfig.json deleted file mode 100644 index 80e7492..0000000 --- a/examples/sdk-typescript/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "declaration": true, - "outDir": "dist", - "rootDir": "src", - "strict": true, - "moduleResolution": "Node" - }, - "include": ["src/**/*"] -} diff --git a/pkg/cli/config_commands.go b/pkg/cli/config_commands.go index 23fed88..84f267e 100644 --- a/pkg/cli/config_commands.go +++ b/pkg/cli/config_commands.go @@ -318,7 +318,7 @@ func initFullStack(force bool) { os.Exit(1) } } - node2Content := GenerateNodeConfig(node2Name, "", 4002, 5002, 7002, "localhost:7001", bootstrapMultiaddr) + node2Content := GenerateNodeConfig(node2Name, "", 4002, 5002, 7002, "localhost:5001", bootstrapMultiaddr) if err := os.WriteFile(node2Path, []byte(node2Content), 0644); err != nil { fmt.Fprintf(os.Stderr, "Failed to write node2 config: %v\n", err) os.Exit(1) @@ -334,7 +334,7 @@ func initFullStack(force bool) { os.Exit(1) } } - node3Content := GenerateNodeConfig(node3Name, "", 4003, 5003, 7003, "localhost:7001", bootstrapMultiaddr) + node3Content := GenerateNodeConfig(node3Name, "", 4003, 5003, 7003, "localhost:5001", bootstrapMultiaddr) if err := os.WriteFile(node3Path, []byte(node3Content), 0644); err != nil { fmt.Fprintf(os.Stderr, "Failed to write node3 config: %v\n", err) os.Exit(1) diff --git a/pkg/cli/rqlite_commands.go b/pkg/cli/rqlite_commands.go new file mode 100644 index 0000000..b9961cb --- /dev/null +++ b/pkg/cli/rqlite_commands.go @@ -0,0 +1,327 @@ +package cli + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/DeBrosOfficial/network/pkg/config" + "gopkg.in/yaml.v3" +) + +// HandleRQLiteCommand handles rqlite-related commands +func HandleRQLiteCommand(args []string) { + if len(args) == 0 { + showRQLiteHelp() + return + } + + if runtime.GOOS != "linux" { + fmt.Fprintf(os.Stderr, "❌ RQLite commands are only supported on Linux\n") + os.Exit(1) + } + + subcommand := args[0] + subargs := args[1:] + + switch subcommand { + case "fix": + handleRQLiteFix(subargs) + case "help": + showRQLiteHelp() + default: + fmt.Fprintf(os.Stderr, "Unknown rqlite subcommand: %s\n", subcommand) + showRQLiteHelp() + os.Exit(1) + } +} + +func showRQLiteHelp() { + fmt.Printf("🗄️ RQLite Commands\n\n") + fmt.Printf("Usage: network-cli rqlite [options]\n\n") + fmt.Printf("Subcommands:\n") + fmt.Printf(" fix - Fix misconfigured join address and clean stale raft state\n\n") + fmt.Printf("Description:\n") + fmt.Printf(" The 'fix' command automatically repairs common rqlite cluster issues:\n") + fmt.Printf(" - Corrects join address from HTTP port (5001) to Raft port (7001) if misconfigured\n") + fmt.Printf(" - Cleans stale raft state that prevents proper cluster formation\n") + fmt.Printf(" - Restarts the node service with corrected configuration\n\n") + fmt.Printf("Requirements:\n") + fmt.Printf(" - Must be run as root (use sudo)\n") + fmt.Printf(" - Only works on non-bootstrap nodes (nodes with join_address configured)\n") + fmt.Printf(" - Stops and restarts the debros-node service\n\n") + fmt.Printf("Examples:\n") + fmt.Printf(" sudo network-cli rqlite fix\n") +} + +func handleRQLiteFix(args []string) { + requireRoot() + + // Parse optional flags + dryRun := false + for _, arg := range args { + if arg == "--dry-run" || arg == "-n" { + dryRun = true + } + } + + if dryRun { + fmt.Printf("🔍 Dry-run mode - no changes will be made\n\n") + } + + fmt.Printf("🔧 RQLite Cluster Repair\n\n") + + // Load config + configPath, err := config.DefaultPath("node.yaml") + if err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to determine config path: %v\n", err) + os.Exit(1) + } + + cfg, err := loadConfigForRepair(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to load config: %v\n", err) + os.Exit(1) + } + + // Check if this is a bootstrap node + if cfg.Node.Type == "bootstrap" || cfg.Database.RQLiteJoinAddress == "" { + fmt.Printf("ℹ️ This is a bootstrap node (no join address configured)\n") + fmt.Printf(" Bootstrap nodes don't need repair - they are the cluster leader\n") + fmt.Printf(" Run this command on follower nodes instead\n") + return + } + + joinAddr := cfg.Database.RQLiteJoinAddress + + // Check if join address needs fixing + needsConfigFix := needsFix(joinAddr, cfg.Database.RQLiteRaftPort, cfg.Database.RQLitePort) + var fixedAddr string + + if needsConfigFix { + fmt.Printf("⚠️ Detected misconfigured join address: %s\n", joinAddr) + fmt.Printf(" Expected Raft port (%d) but found HTTP port (%d)\n", cfg.Database.RQLiteRaftPort, cfg.Database.RQLitePort) + + // Extract host from join address + host, _, err := parseJoinAddress(joinAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to parse join address: %v\n", err) + os.Exit(1) + } + + // Fix the join address - rqlite expects Raft port for -join + fixedAddr = fmt.Sprintf("%s:%d", host, cfg.Database.RQLiteRaftPort) + fmt.Printf(" Corrected address: %s\n\n", fixedAddr) + } else { + fmt.Printf("✅ Join address looks correct: %s\n", joinAddr) + fmt.Printf(" Will clean stale raft state to ensure proper cluster formation\n\n") + fixedAddr = joinAddr // No change needed + } + + if dryRun { + fmt.Printf("🔍 Dry-run: Would clean raft state") + if needsConfigFix { + fmt.Printf(" and fix config") + } + fmt.Printf("\n") + return + } + + // Stop the service + fmt.Printf("⏹️ Stopping debros-node service...\n") + if err := stopService("debros-node"); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to stop service: %v\n", err) + os.Exit(1) + } + fmt.Printf(" ✓ Service stopped\n\n") + + // Update config file if needed + if needsConfigFix { + fmt.Printf("📝 Updating configuration file...\n") + if err := updateConfigJoinAddress(configPath, fixedAddr); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to update config: %v\n", err) + fmt.Fprintf(os.Stderr, " Service is stopped - please fix manually and restart\n") + os.Exit(1) + } + fmt.Printf(" ✓ Config updated: %s\n\n", configPath) + } + + // Clean raft state + fmt.Printf("🧹 Cleaning stale raft state...\n") + dataDir := expandDataDir(cfg.Node.DataDir) + raftDir := filepath.Join(dataDir, "rqlite", "raft") + if err := cleanRaftState(raftDir); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to clean raft state: %v\n", err) + fmt.Fprintf(os.Stderr, " Continuing anyway - raft state may still exist\n") + } else { + fmt.Printf(" ✓ Raft state cleaned\n\n") + } + + // Restart the service + fmt.Printf("🚀 Restarting debros-node service...\n") + if err := startService("debros-node"); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to start service: %v\n", err) + fmt.Fprintf(os.Stderr, " Config has been fixed - please restart manually:\n") + fmt.Fprintf(os.Stderr, " sudo systemctl start debros-node\n") + os.Exit(1) + } + fmt.Printf(" ✓ Service started\n\n") + + fmt.Printf("✅ Repair complete!\n\n") + fmt.Printf("The node should now join the cluster correctly.\n") + fmt.Printf("Monitor logs with: sudo network-cli service logs node --follow\n") +} + +func loadConfigForRepair(path string) (*config.Config, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open config file: %w", err) + } + defer file.Close() + + var cfg config.Config + if err := config.DecodeStrict(file, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + return &cfg, nil +} + +func needsFix(joinAddr string, raftPort int, httpPort int) bool { + if joinAddr == "" { + return false + } + + // Remove http:// or https:// prefix if present + addr := joinAddr + if strings.HasPrefix(addr, "http://") { + addr = strings.TrimPrefix(addr, "http://") + } else if strings.HasPrefix(addr, "https://") { + addr = strings.TrimPrefix(addr, "https://") + } + + // Parse host:port + _, port, err := net.SplitHostPort(addr) + if err != nil { + return false // Can't parse, assume it's fine + } + + // Check if port matches HTTP port (incorrect - should be Raft port) + if port == fmt.Sprintf("%d", httpPort) { + return true + } + + // If it matches Raft port, it's correct + if port == fmt.Sprintf("%d", raftPort) { + return false + } + + // Unknown port - assume it's fine + return false +} + +func parseJoinAddress(joinAddr string) (host, port string, err error) { + // Remove http:// or https:// prefix if present + addr := joinAddr + if strings.HasPrefix(addr, "http://") { + addr = strings.TrimPrefix(addr, "http://") + } else if strings.HasPrefix(addr, "https://") { + addr = strings.TrimPrefix(addr, "https://") + } + + host, port, err = net.SplitHostPort(addr) + if err != nil { + return "", "", fmt.Errorf("invalid join address format: %w", err) + } + + return host, port, nil +} + +func updateConfigJoinAddress(configPath string, newJoinAddr string) error { + // Read the file + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + // Parse YAML into a generic map to preserve structure + var yamlData map[string]interface{} + if err := yaml.Unmarshal(data, &yamlData); err != nil { + return fmt.Errorf("failed to parse YAML: %w", err) + } + + // Navigate to database.rqlite_join_address + database, ok := yamlData["database"].(map[string]interface{}) + if !ok { + return fmt.Errorf("database section not found in config") + } + + database["rqlite_join_address"] = newJoinAddr + + // Write back to file + updatedData, err := yaml.Marshal(yamlData) + if err != nil { + return fmt.Errorf("failed to marshal YAML: %w", err) + } + + if err := os.WriteFile(configPath, updatedData, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +func expandDataDir(dataDir string) string { + expanded := os.ExpandEnv(dataDir) + if strings.HasPrefix(expanded, "~") { + home, err := os.UserHomeDir() + if err != nil { + return expanded // Fallback to original + } + expanded = filepath.Join(home, expanded[1:]) + } + return expanded +} + +func cleanRaftState(raftDir string) error { + if _, err := os.Stat(raftDir); os.IsNotExist(err) { + return nil // Directory doesn't exist, nothing to clean + } + + // Remove raft state files + filesToRemove := []string{ + "peers.json", + "peers.json.backup", + "peers.info", + "raft.db", + } + + for _, file := range filesToRemove { + filePath := filepath.Join(raftDir, file) + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove %s: %w", filePath, err) + } + } + + return nil +} + +func stopService(serviceName string) error { + cmd := exec.Command("systemctl", "stop", serviceName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("systemctl stop failed: %w", err) + } + return nil +} + +func startService(serviceName string) error { + cmd := exec.Command("systemctl", "start", serviceName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("systemctl start failed: %w", err) + } + return nil +} diff --git a/pkg/cli/setup.go b/pkg/cli/setup.go index b78045b..c1a7ed7 100644 --- a/pkg/cli/setup.go +++ b/pkg/cli/setup.go @@ -3,12 +3,16 @@ package cli import ( "bufio" "fmt" + "net" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" + "time" + + "github.com/DeBrosOfficial/network/pkg/config" ) // HandleSetupCommand handles the interactive 'setup' command for VPS installation @@ -108,19 +112,179 @@ func HandleSetupCommand(args []string) { fmt.Printf("✅ Setup Complete!\n") fmt.Printf(strings.Repeat("=", 70) + "\n\n") fmt.Printf("DeBros Network is now running!\n\n") + + // Try to get and display peer ID + peerID := getPeerID() + if peerID != "" { + fmt.Printf("🆔 Node Peer ID: %s\n\n", peerID) + } + fmt.Printf("Service Management:\n") fmt.Printf(" network-cli service status all\n") fmt.Printf(" network-cli service logs node --follow\n") fmt.Printf(" network-cli service restart gateway\n\n") fmt.Printf("Access DeBros User:\n") fmt.Printf(" sudo -u debros bash\n\n") + + // Check if HTTPS is enabled + gatewayConfigPath := "/home/debros/.debros/gateway.yaml" + httpsEnabled := false + var domainName string + if data, err := os.ReadFile(gatewayConfigPath); err == nil { + var cfg config.Config + if err := config.DecodeStrict(strings.NewReader(string(data)), &cfg); err == nil { + // Try to parse as gateway config + if strings.Contains(string(data), "enable_https: true") { + httpsEnabled = true + // Extract domain name from config + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "domain_name:") { + parts := strings.Split(line, ":") + if len(parts) > 1 { + domainName = strings.Trim(strings.TrimSpace(parts[1]), "\"") + } + break + } + } + } + } + } + fmt.Printf("Verify Installation:\n") - fmt.Printf(" curl http://localhost:6001/health\n") + if httpsEnabled && domainName != "" { + fmt.Printf(" curl https://%s/health\n", domainName) + fmt.Printf(" curl http://localhost:6001/health (HTTP fallback)\n") + } else { + fmt.Printf(" curl http://localhost:6001/health\n") + } fmt.Printf(" curl http://localhost:5001/status\n\n") + + if httpsEnabled && domainName != "" { + fmt.Printf("HTTPS Configuration:\n") + fmt.Printf(" Domain: %s\n", domainName) + fmt.Printf(" HTTPS endpoint: https://%s\n", domainName) + fmt.Printf(" Certificate cache: /home/debros/.debros/tls-cache\n") + fmt.Printf(" Certificates are automatically managed via Let's Encrypt (ACME)\n\n") + } + fmt.Printf("Anyone Relay (Anon):\n") fmt.Printf(" sudo systemctl status anon\n") fmt.Printf(" sudo tail -f /home/debros/.debros/logs/anon/notices.log\n") - fmt.Printf(" Proxy endpoint: POST http://localhost:6001/v1/proxy/anon\n\n") + if httpsEnabled && domainName != "" { + fmt.Printf(" Proxy endpoint: POST https://%s/v1/proxy/anon\n\n", domainName) + } else { + fmt.Printf(" Proxy endpoint: POST http://localhost:6001/v1/proxy/anon\n\n") + } +} + +// extractIPFromMultiaddr extracts the IP address from a multiaddr string +// Format: /ip4/51.83.128.181/tcp/4001/p2p/12D3KooW... +func extractIPFromMultiaddr(multiaddr string) string { + if multiaddr == "" { + return "" + } + + // Split by "/ip4/" + parts := strings.Split(multiaddr, "/ip4/") + if len(parts) < 2 { + return "" + } + + // Get the part after "/ip4/" + ipPart := parts[1] + // Extract IP until the next "/" + ipEnd := strings.Index(ipPart, "/") + if ipEnd == -1 { + // If no "/" found, the whole string might be the IP + return strings.TrimSpace(ipPart) + } + + ip := strings.TrimSpace(ipPart[:ipEnd]) + // Validate it looks like an IP address + if net.ParseIP(ip) != nil { + return ip + } + + return "" +} + +// getVPSIPv4Address gets the primary IPv4 address of the VPS +func getVPSIPv4Address() (string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return "", err + } + + for _, iface := range interfaces { + // Skip loopback and down interfaces + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + + ip := ipNet.IP + // Check if it's IPv4 and not a loopback address + if ip.To4() != nil && !ip.IsLoopback() { + return ip.String(), nil + } + } + } + + return "", fmt.Errorf("could not find a non-loopback IPv4 address") +} + +// getPeerID attempts to retrieve the peer ID from peer.info based on node type +func getPeerID() string { + debrosDir := "/home/debros/.debros" + nodeConfigPath := filepath.Join(debrosDir, "node.yaml") + + // Determine node type from config + var nodeType string + if file, err := os.Open(nodeConfigPath); err == nil { + defer file.Close() + var cfg config.Config + if err := config.DecodeStrict(file, &cfg); err == nil { + nodeType = cfg.Node.Type + } + } + + // Determine the peer.info path based on node type + var peerInfoPath string + if nodeType == "bootstrap" { + peerInfoPath = filepath.Join(debrosDir, "bootstrap", "peer.info") + } else { + // Default to "node" directory for regular nodes + peerInfoPath = filepath.Join(debrosDir, "node", "peer.info") + } + + // Try to read from peer.info file + if data, err := os.ReadFile(peerInfoPath); err == nil { + peerInfo := strings.TrimSpace(string(data)) + // Extract peer ID from multiaddr format: /ip4/.../p2p/ + if strings.Contains(peerInfo, "/p2p/") { + parts := strings.Split(peerInfo, "/p2p/") + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + // If it's just the peer ID, return it + if len(peerInfo) > 0 && !strings.Contains(peerInfo, "/") { + return peerInfo + } + } + + return "" } func detectLinuxDistro() string { @@ -214,6 +378,206 @@ func isValidHostPort(s string) bool { return true } +// isPortAvailable checks if a port is available for binding +func isPortAvailable(port int) bool { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return false + } + ln.Close() + return true +} + +// checkPorts80And443 checks if ports 80 and 443 are available +func checkPorts80And443() (bool, string) { + port80Available := isPortAvailable(80) + port443Available := isPortAvailable(443) + + if !port80Available || !port443Available { + var issues []string + if !port80Available { + issues = append(issues, "port 80") + } + if !port443Available { + issues = append(issues, "port 443") + } + return false, strings.Join(issues, " and ") + } + + return true, "" +} + +// isValidDomain validates a domain name format +func isValidDomain(domain string) bool { + domain = strings.TrimSpace(domain) + if domain == "" { + return false + } + + // Basic validation: domain should contain at least one dot + // and not start/end with dot or hyphen + if !strings.Contains(domain, ".") { + return false + } + + if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") { + return false + } + + if strings.HasPrefix(domain, "-") || strings.HasSuffix(domain, "-") { + return false + } + + // Check for valid characters (letters, numbers, dots, hyphens) + for _, char := range domain { + if !((char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '.' || + char == '-') { + return false + } + } + + return true +} + +// verifyDNSResolution verifies that a domain resolves to the VPS IP +func verifyDNSResolution(domain, expectedIP string) bool { + ips, err := net.LookupIP(domain) + if err != nil { + return false + } + + for _, ip := range ips { + if ip.To4() != nil && ip.String() == expectedIP { + return true + } + } + + return false +} + +// promptDomainForHTTPS prompts for domain name and verifies DNS configuration +func promptDomainForHTTPS(reader *bufio.Reader, vpsIP string) string { + for { + fmt.Printf("\nEnter your domain name (e.g., example.com): ") + domainInput, _ := reader.ReadString('\n') + domain := strings.TrimSpace(domainInput) + + if domain == "" { + fmt.Printf(" Domain name cannot be empty. Skipping HTTPS configuration.\n") + return "" + } + + if !isValidDomain(domain) { + fmt.Printf(" ❌ Invalid domain format. Please enter a valid domain name.\n") + continue + } + + // Verify DNS is configured + fmt.Printf("\n Verifying DNS configuration...\n") + fmt.Printf(" Please ensure your domain %s points to this server's IP (%s)\n", domain, vpsIP) + fmt.Printf(" Have you configured the DNS record? (yes/no): ") + dnsResponse, _ := reader.ReadString('\n') + dnsResponse = strings.ToLower(strings.TrimSpace(dnsResponse)) + + if dnsResponse == "yes" || dnsResponse == "y" { + // Try to verify DNS resolution + fmt.Printf(" Checking DNS resolution...\n") + if verifyDNSResolution(domain, vpsIP) { + fmt.Printf(" ✓ DNS is correctly configured\n") + return domain + } else { + fmt.Printf(" ⚠️ DNS does not resolve to this server's IP (%s)\n", vpsIP) + fmt.Printf(" DNS may still be propagating. Continue anyway? (yes/no): ") + continueResponse, _ := reader.ReadString('\n') + continueResponse = strings.ToLower(strings.TrimSpace(continueResponse)) + if continueResponse == "yes" || continueResponse == "y" { + fmt.Printf(" Continuing with domain configuration (DNS may need time to propagate)\n") + return domain + } + // User chose not to continue, ask for domain again + fmt.Printf(" Please configure DNS and try again, or press Enter to skip HTTPS\n") + continue + } + } else { + fmt.Printf(" Please configure DNS first. Type 'skip' to skip HTTPS configuration: ") + skipResponse, _ := reader.ReadString('\n') + skipResponse = strings.ToLower(strings.TrimSpace(skipResponse)) + if skipResponse == "skip" { + return "" + } + continue + } + } +} + +// updateGatewayConfigWithHTTPS updates an existing gateway.yaml file with HTTPS settings +func updateGatewayConfigWithHTTPS(gatewayPath, domain string) error { + // Read existing config + data, err := os.ReadFile(gatewayPath) + if err != nil { + return fmt.Errorf("failed to read gateway config: %w", err) + } + + configContent := string(data) + tlsCacheDir := "/home/debros/.debros/tls-cache" + + // Check if HTTPS is already enabled + if strings.Contains(configContent, "enable_https: true") { + // Update existing HTTPS settings + lines := strings.Split(configContent, "\n") + var updatedLines []string + domainUpdated := false + cacheDirUpdated := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "enable_https:") { + updatedLines = append(updatedLines, "enable_https: true") + } else if strings.HasPrefix(trimmed, "domain_name:") { + updatedLines = append(updatedLines, fmt.Sprintf("domain_name: \"%s\"", domain)) + domainUpdated = true + } else if strings.HasPrefix(trimmed, "tls_cache_dir:") { + updatedLines = append(updatedLines, fmt.Sprintf("tls_cache_dir: \"%s\"", tlsCacheDir)) + cacheDirUpdated = true + } else { + updatedLines = append(updatedLines, line) + } + } + + // Add missing fields if not found + if !domainUpdated { + updatedLines = append(updatedLines, fmt.Sprintf("domain_name: \"%s\"", domain)) + } + if !cacheDirUpdated { + updatedLines = append(updatedLines, fmt.Sprintf("tls_cache_dir: \"%s\"", tlsCacheDir)) + } + + configContent = strings.Join(updatedLines, "\n") + } else { + // Add HTTPS configuration at the end + configContent = strings.TrimRight(configContent, "\n") + if !strings.HasSuffix(configContent, "\n") && configContent != "" { + configContent += "\n" + } + configContent += "enable_https: true\n" + configContent += fmt.Sprintf("domain_name: \"%s\"\n", domain) + configContent += fmt.Sprintf("tls_cache_dir: \"%s\"\n", tlsCacheDir) + } + + // Write updated config + if err := os.WriteFile(gatewayPath, []byte(configContent), 0644); err != nil { + return fmt.Errorf("failed to write gateway config: %w", err) + } + + // Fix ownership + exec.Command("chown", "debros:debros", gatewayPath).Run() + + return nil +} + func setupDebrosUser() { fmt.Printf("👤 Setting up 'debros' user...\n") @@ -704,38 +1068,37 @@ func cloneAndBuild() { branch := promptBranch() fmt.Printf(" Using branch: %s\n", branch) - // Check if already cloned - if _, err := os.Stat("/home/debros/src/.git"); err == nil { - fmt.Printf(" Updating repository...\n") - - // Check current branch and switch if needed - currentBranchCmd := exec.Command("sudo", "-u", "debros", "git", "-C", "/home/debros/src", "rev-parse", "--abbrev-ref", "HEAD") - if output, err := currentBranchCmd.Output(); err == nil { - currentBranch := strings.TrimSpace(string(output)) - if currentBranch != branch { - fmt.Printf(" Switching from %s to %s...\n", currentBranch, branch) - // Fetch the target branch first (needed for shallow clones) - exec.Command("sudo", "-u", "debros", "git", "-C", "/home/debros/src", "fetch", "origin", branch).Run() - // Checkout the selected branch - checkoutCmd := exec.Command("sudo", "-u", "debros", "git", "-C", "/home/debros/src", "checkout", branch) - if err := checkoutCmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "⚠️ Failed to switch branch: %v\n", err) - } + // Remove existing repository if it exists (always start fresh) + if _, err := os.Stat("/home/debros/src"); err == nil { + fmt.Printf(" Removing existing repository...\n") + // Remove as root since we're running as root + if err := os.RemoveAll("/home/debros/src"); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to remove existing repo as root: %v\n", err) + // Try as debros user as fallback (might work if files are owned by debros) + removeCmd := exec.Command("sudo", "-u", "debros", "rm", "-rf", "/home/debros/src") + if output, err := removeCmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to remove existing repo as debros user: %v\n%s\n", err, output) } } + // Wait a moment to ensure filesystem syncs + time.Sleep(100 * time.Millisecond) + } - // Pull latest changes - cmd := exec.Command("sudo", "-u", "debros", "git", "-C", "/home/debros/src", "pull", "origin", branch) - if err := cmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "⚠️ Failed to update repo: %v\n", err) - } - } else { - fmt.Printf(" Cloning repository...\n") - cmd := exec.Command("sudo", "-u", "debros", "git", "clone", "--branch", branch, "--depth", "1", "https://github.com/DeBrosOfficial/network.git", "/home/debros/src") - if err := cmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "❌ Failed to clone repo: %v\n", err) - os.Exit(1) - } + // Ensure parent directory exists and has correct permissions + if err := os.MkdirAll("/home/debros", 0755); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to ensure debros home directory exists: %v\n", err) + os.Exit(1) + } + if err := exec.Command("chown", "debros:debros", "/home/debros").Run(); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to chown debros home directory: %v\n", err) + } + + // Clone fresh repository + fmt.Printf(" Cloning repository...\n") + cmd := exec.Command("sudo", "-u", "debros", "git", "clone", "--branch", branch, "--depth", "1", "https://github.com/DeBrosOfficial/network.git", "/home/debros/src") + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to clone repo: %v\n%s\n", err, output) + os.Exit(1) } // Build @@ -746,7 +1109,7 @@ func cloneAndBuild() { // Use sudo with --preserve-env=PATH to pass Go path to debros user // Set HOME so Go knows where to create module cache - cmd := exec.Command("sudo", "--preserve-env=PATH", "-u", "debros", "make", "build") + cmd = exec.Command("sudo", "--preserve-env=PATH", "-u", "debros", "make", "build") cmd.Dir = "/home/debros/src" cmd.Env = append(os.Environ(), "HOME=/home/debros", "PATH="+os.Getenv("PATH")+":/usr/local/go/bin") if output, err := cmd.CombinedOutput(); err != nil { @@ -755,92 +1118,176 @@ func cloneAndBuild() { } // Copy binaries - exec.Command("sh", "-c", "cp -r /home/debros/src/bin/* /home/debros/bin/").Run() - exec.Command("chown", "-R", "debros:debros", "/home/debros/bin").Run() - exec.Command("chmod", "-R", "755", "/home/debros/bin").Run() + copyCmd := exec.Command("sh", "-c", "cp -r /home/debros/src/bin/* /home/debros/bin/") + if output, err := copyCmd.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to copy binaries: %v\n%s\n", err, output) + os.Exit(1) + } + + chownCmd := exec.Command("chown", "-R", "debros:debros", "/home/debros/bin") + if err := chownCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to chown binaries: %v\n", err) + } + + chmodCmd := exec.Command("chmod", "-R", "755", "/home/debros/bin") + if err := chmodCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to chmod binaries: %v\n", err) + } fmt.Printf(" ✓ Built and installed\n") } func generateConfigsInteractive(force bool) { - fmt.Printf("⚙️ Generating configurations...\n") + fmt.Printf("⚙️ Generating configurations...\n\n") - // For single-node VPS setup, use sensible defaults - // This creates a bootstrap node that acts as the cluster leader - fmt.Printf("\n") - fmt.Printf("Setting up single-node configuration...\n") - fmt.Printf(" • Bootstrap node (cluster leader)\n") - fmt.Printf(" • No external peers required\n") - fmt.Printf(" • Gateway connected to local node\n\n") - - bootstrapPath := "/home/debros/.debros/bootstrap.yaml" nodeConfigPath := "/home/debros/.debros/node.yaml" gatewayPath := "/home/debros/.debros/gateway.yaml" - // Check if node.yaml already exists + // Check if configs already exist nodeExists := false + gatewayExists := false if _, err := os.Stat(nodeConfigPath); err == nil { nodeExists = true - fmt.Printf(" ℹ️ node.yaml already exists, will not overwrite\n") + } + if _, err := os.Stat(gatewayPath); err == nil { + gatewayExists = true } - // Generate bootstrap node config with explicit parameters - // Pass empty bootstrap-peers and no join address for bootstrap node - bootstrapArgs := []string{ - "-u", "debros", - "/home/debros/bin/network-cli", "config", "init", - "--type", "bootstrap", - "--bootstrap-peers", "", - } - if force { - bootstrapArgs = append(bootstrapArgs, "--force") - } + // If both configs exist and not forcing, skip configuration prompts + if nodeExists && gatewayExists && !force { + fmt.Printf(" ℹ️ Configuration files already exist (node.yaml and gateway.yaml)\n") + fmt.Printf(" ℹ️ Skipping configuration generation\n\n") - cmd := exec.Command("sudo", bootstrapArgs...) - cmd.Stdin = nil // Explicitly close stdin to prevent interactive prompts - output, err := cmd.CombinedOutput() - bootstrapCreated := (err == nil) + // Only offer to add HTTPS if not already enabled + httpsAlreadyEnabled := false + if data, err := os.ReadFile(gatewayPath); err == nil { + httpsAlreadyEnabled = strings.Contains(string(data), "enable_https: true") + } - if err != nil { - // Check if bootstrap.yaml already exists (config init failed because it exists) - if _, statErr := os.Stat(bootstrapPath); statErr == nil { - fmt.Printf(" ℹ️ bootstrap.yaml already exists, skipping creation\n") - bootstrapCreated = true + if !httpsAlreadyEnabled { + fmt.Printf("🌐 Domain and HTTPS Configuration\n") + fmt.Printf("Would you like to add HTTPS with a domain name to your existing gateway config? (yes/no) [default: no]: ") + reader := bufio.NewReader(os.Stdin) + addHTTPSResponse, _ := reader.ReadString('\n') + addHTTPSResponse = strings.ToLower(strings.TrimSpace(addHTTPSResponse)) + + if addHTTPSResponse == "yes" || addHTTPSResponse == "y" { + // Get VPS IP for DNS verification + vpsIP, err := getVPSIPv4Address() + if err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to detect IPv4 address: %v\n", err) + fmt.Fprintf(os.Stderr, " Using 0.0.0.0 as fallback\n") + vpsIP = "0.0.0.0" + } + + // Check if ports 80 and 443 are available + portsAvailable, portIssues := checkPorts80And443() + if !portsAvailable { + fmt.Fprintf(os.Stderr, "\n⚠️ Cannot enable HTTPS: %s is already in use\n", portIssues) + fmt.Fprintf(os.Stderr, " You will need to configure HTTPS manually if you want to use a domain.\n\n") + } else { + // Prompt for domain and update existing config + domain := promptDomainForHTTPS(reader, vpsIP) + if domain != "" { + // Update existing gateway config with HTTPS settings + if err := updateGatewayConfigWithHTTPS(gatewayPath, domain); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to update gateway config with HTTPS: %v\n", err) + } else { + fmt.Printf(" ✓ HTTPS configuration added to existing gateway.yaml\n") + // Create TLS cache directory + tlsCacheDir := "/home/debros/.debros/tls-cache" + if err := os.MkdirAll(tlsCacheDir, 0755); err == nil { + exec.Command("chown", "-R", "debros:debros", tlsCacheDir).Run() + fmt.Printf(" ✓ TLS cache directory created: %s\n", tlsCacheDir) + } + } + } + } + } } else { - fmt.Fprintf(os.Stderr, "⚠️ Failed to generate bootstrap config: %v\n", err) - if len(output) > 0 { - fmt.Fprintf(os.Stderr, " Output: %s\n", string(output)) - } + fmt.Printf(" ℹ️ HTTPS is already enabled in gateway.yaml\n") } + + fmt.Printf("\n ✓ Configurations ready\n") + return + } + + // Get VPS IPv4 address + fmt.Printf("Detecting VPS IPv4 address...\n") + vpsIP, err := getVPSIPv4Address() + if err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to detect IPv4 address: %v\n", err) + fmt.Fprintf(os.Stderr, " Using 0.0.0.0 as fallback. You may need to edit config files manually.\n") + vpsIP = "0.0.0.0" } else { - fmt.Printf(" ✓ Bootstrap node config created\n") + fmt.Printf(" ✓ Detected IPv4 address: %s\n\n", vpsIP) } - // Rename bootstrap.yaml to node.yaml only if node.yaml doesn't exist - if !nodeExists && bootstrapCreated { - // Check if bootstrap.yaml exists before renaming - if _, err := os.Stat(bootstrapPath); err == nil { - renameCmd := exec.Command("sudo", "-u", "debros", "mv", bootstrapPath, nodeConfigPath) - if err := renameCmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "⚠️ Failed to rename config: %v\n", err) + // Ask about node type + fmt.Printf("What type of node is this?\n") + fmt.Printf(" 1. Bootstrap node (cluster leader)\n") + fmt.Printf(" 2. Regular node (joins existing cluster)\n") + fmt.Printf("Enter choice (1 or 2): ") + reader := bufio.NewReader(os.Stdin) + choice, _ := reader.ReadString('\n') + choice = strings.ToLower(strings.TrimSpace(choice)) + + isBootstrap := choice == "1" || choice == "bootstrap" || choice == "b" + + var bootstrapPeers string + if !isBootstrap { + // Ask for bootstrap peer multiaddr + fmt.Printf("\nEnter bootstrap peer multiaddr(s) (comma-separated if multiple):\n") + fmt.Printf("Example: /ip4/192.168.1.100/tcp/4001/p2p/12D3KooW...\n") + fmt.Printf("Bootstrap peer(s): ") + bootstrapPeers, _ = reader.ReadString('\n') + bootstrapPeers = strings.TrimSpace(bootstrapPeers) + } + + // Check if node.yaml already exists + if _, err := os.Stat(nodeConfigPath); err == nil { + nodeExists = true + if !force { + fmt.Printf("\n ℹ️ node.yaml already exists, will not overwrite\n") + } + } + + // Generate node config + if !nodeExists || force { + var nodeConfig string + if isBootstrap { + nodeConfig = generateBootstrapConfigWithIP("bootstrap", "", 4001, 5001, 7001, vpsIP) + } else { + // Extract IP from bootstrap peer multiaddr for rqlite_join_address + // Use first bootstrap peer if multiple provided + const defaultRQLiteHTTPPort = 5001 + var joinAddr string + if bootstrapPeers != "" { + firstPeer := strings.Split(bootstrapPeers, ",")[0] + firstPeer = strings.TrimSpace(firstPeer) + extractedIP := extractIPFromMultiaddr(firstPeer) + if extractedIP != "" { + joinAddr = fmt.Sprintf("%s:%d", extractedIP, defaultRQLiteHTTPPort) + } else { + joinAddr = fmt.Sprintf("localhost:%d", defaultRQLiteHTTPPort) + } } else { - fmt.Printf(" ✓ Renamed bootstrap.yaml to node.yaml\n") + joinAddr = fmt.Sprintf("localhost:%d", defaultRQLiteHTTPPort) } + nodeConfig = generateNodeConfigWithIP("node", "", 4001, 5001, 7001, joinAddr, bootstrapPeers, vpsIP) } - } else if nodeExists { - // If node.yaml exists, we can optionally remove bootstrap.yaml if it was just created - if bootstrapCreated && !force { - // Clean up bootstrap.yaml if it was just created but node.yaml already exists - if _, err := os.Stat(bootstrapPath); err == nil { - exec.Command("sudo", "-u", "debros", "rm", "-f", bootstrapPath).Run() - } + + // Write node config + if err := os.WriteFile(nodeConfigPath, []byte(nodeConfig), 0644); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to write node config: %v\n", err) + os.Exit(1) } - fmt.Printf(" ℹ️ Using existing node.yaml\n") + // Fix ownership + exec.Command("chown", "debros:debros", nodeConfigPath).Run() + fmt.Printf(" ✓ Node config created: %s\n", nodeConfigPath) } - // Generate gateway config with explicit empty bootstrap peers - // Check if gateway.yaml already exists - gatewayExists := false + // Generate gateway config if _, err := os.Stat(gatewayPath); err == nil { gatewayExists = true if !force { @@ -849,35 +1296,215 @@ func generateConfigsInteractive(force bool) { } if !gatewayExists || force { - gatewayArgs := []string{ - "-u", "debros", - "/home/debros/bin/network-cli", "config", "init", - "--type", "gateway", - "--bootstrap-peers", "", - } - if force { - gatewayArgs = append(gatewayArgs, "--force") - } + // Prompt for domain and HTTPS configuration + var domain string + var enableHTTPS bool + var tlsCacheDir string - cmd = exec.Command("sudo", gatewayArgs...) - cmd.Stdin = nil // Explicitly close stdin to prevent interactive prompts - output, err = cmd.CombinedOutput() - if err != nil { - // Check if gateway.yaml already exists (config init failed because it exists) - if _, statErr := os.Stat(gatewayPath); statErr == nil { - fmt.Printf(" ℹ️ gateway.yaml already exists, skipping creation\n") + fmt.Printf("\n🌐 Domain and HTTPS Configuration\n") + fmt.Printf("Would you like to configure HTTPS with a domain name? (yes/no) [default: no]: ") + response, _ := reader.ReadString('\n') + response = strings.ToLower(strings.TrimSpace(response)) + + if response == "yes" || response == "y" { + // Check if ports 80 and 443 are available + portsAvailable, portIssues := checkPorts80And443() + if !portsAvailable { + fmt.Fprintf(os.Stderr, "\n⚠️ Cannot enable HTTPS: %s is already in use\n", portIssues) + fmt.Fprintf(os.Stderr, " You will need to configure HTTPS manually if you want to use a domain.\n") + fmt.Fprintf(os.Stderr, " Continuing without HTTPS configuration...\n\n") + enableHTTPS = false } else { - fmt.Fprintf(os.Stderr, "⚠️ Failed to generate gateway config: %v\n", err) - if len(output) > 0 { - fmt.Fprintf(os.Stderr, " Output: %s\n", string(output)) + // Prompt for domain name + domain = promptDomainForHTTPS(reader, vpsIP) + if domain != "" { + enableHTTPS = true + // Set TLS cache directory if HTTPS is enabled + tlsCacheDir = "/home/debros/.debros/tls-cache" + // Create TLS cache directory + if err := os.MkdirAll(tlsCacheDir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "⚠️ Failed to create TLS cache directory: %v\n", err) + } else { + exec.Command("chown", "-R", "debros:debros", tlsCacheDir).Run() + fmt.Printf(" ✓ TLS cache directory created: %s\n", tlsCacheDir) + } + } else { + enableHTTPS = false } } - } else { - fmt.Printf(" ✓ Gateway config created\n") + } + + // Gateway config should include bootstrap peers if this is a regular node + // (bootstrap nodes don't need bootstrap peers since they are the bootstrap) + gatewayConfig := generateGatewayConfigDirect(bootstrapPeers, enableHTTPS, domain, tlsCacheDir) + if err := os.WriteFile(gatewayPath, []byte(gatewayConfig), 0644); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to write gateway config: %v\n", err) + os.Exit(1) + } + // Fix ownership + exec.Command("chown", "debros:debros", gatewayPath).Run() + fmt.Printf(" ✓ Gateway config created: %s\n", gatewayPath) + } + + fmt.Printf("\n ✓ Configurations ready\n") +} + +// generateBootstrapConfigWithIP generates a bootstrap config with actual IP address +func generateBootstrapConfigWithIP(name, id string, listenPort, rqliteHTTPPort, rqliteRaftPort int, ipAddr string) string { + nodeID := id + if nodeID == "" { + nodeID = "bootstrap" + } + + dataDir := "/home/debros/.debros/bootstrap" + + return fmt.Sprintf(`node: + id: "%s" + type: "bootstrap" + listen_addresses: + - "/ip4/%s/tcp/%d" + data_dir: "%s" + max_connections: 50 + +database: + data_dir: "%s/rqlite" + replication_factor: 3 + shard_count: 16 + max_database_size: 1073741824 + backup_interval: "24h" + rqlite_port: %d + rqlite_raft_port: %d + rqlite_join_address: "" + cluster_sync_interval: "30s" + peer_inactivity_limit: "24h" + min_cluster_size: 1 + +discovery: + bootstrap_peers: [] + discovery_interval: "15s" + bootstrap_port: %d + http_adv_address: "%s:%d" + raft_adv_address: "%s:%d" + node_namespace: "default" + +security: + enable_tls: false + +logging: + level: "info" + format: "console" +`, nodeID, ipAddr, listenPort, dataDir, dataDir, rqliteHTTPPort, rqliteRaftPort, 4001, ipAddr, rqliteHTTPPort, ipAddr, rqliteRaftPort) +} + +// generateNodeConfigWithIP generates a node config with actual IP address +func generateNodeConfigWithIP(name, id string, listenPort, rqliteHTTPPort, rqliteRaftPort int, joinAddr, bootstrapPeers, ipAddr string) string { + nodeID := id + if nodeID == "" { + nodeID = fmt.Sprintf("node-%d", time.Now().Unix()) + } + + dataDir := "/home/debros/.debros/node" + + // Parse bootstrap peers + var peers []string + if bootstrapPeers != "" { + for _, p := range strings.Split(bootstrapPeers, ",") { + if p = strings.TrimSpace(p); p != "" { + peers = append(peers, p) + } } } - fmt.Printf(" ✓ Configurations ready\n") + var peersYAML strings.Builder + if len(peers) == 0 { + peersYAML.WriteString(" bootstrap_peers: []") + } else { + peersYAML.WriteString(" bootstrap_peers:\n") + for _, p := range peers { + fmt.Fprintf(&peersYAML, " - \"%s\"\n", p) + } + } + + if joinAddr == "" { + joinAddr = fmt.Sprintf("localhost:%d", rqliteHTTPPort) + } + + return fmt.Sprintf(`node: + id: "%s" + type: "node" + listen_addresses: + - "/ip4/%s/tcp/%d" + data_dir: "%s" + max_connections: 50 + +database: + data_dir: "%s/rqlite" + replication_factor: 3 + shard_count: 16 + max_database_size: 1073741824 + backup_interval: "24h" + rqlite_port: %d + rqlite_raft_port: %d + rqlite_join_address: "%s" + cluster_sync_interval: "30s" + peer_inactivity_limit: "24h" + min_cluster_size: 1 + +discovery: +%s + discovery_interval: "15s" + bootstrap_port: %d + http_adv_address: "%s:%d" + raft_adv_address: "%s:%d" + node_namespace: "default" + +security: + enable_tls: false + +logging: + level: "info" + format: "console" +`, nodeID, ipAddr, listenPort, dataDir, dataDir, rqliteHTTPPort, rqliteRaftPort, joinAddr, peersYAML.String(), 4001, ipAddr, rqliteHTTPPort, ipAddr, rqliteRaftPort) +} + +// generateGatewayConfigDirect generates gateway config directly +func generateGatewayConfigDirect(bootstrapPeers string, enableHTTPS bool, domain, tlsCacheDir string) string { + var peers []string + if bootstrapPeers != "" { + for _, p := range strings.Split(bootstrapPeers, ",") { + if p = strings.TrimSpace(p); p != "" { + peers = append(peers, p) + } + } + } + + var peersYAML strings.Builder + if len(peers) == 0 { + peersYAML.WriteString("bootstrap_peers: []") + } else { + peersYAML.WriteString("bootstrap_peers:\n") + for _, p := range peers { + fmt.Fprintf(&peersYAML, " - \"%s\"\n", p) + } + } + + var httpsYAML strings.Builder + if enableHTTPS && domain != "" { + fmt.Fprintf(&httpsYAML, "enable_https: true\n") + fmt.Fprintf(&httpsYAML, "domain_name: \"%s\"\n", domain) + if tlsCacheDir != "" { + fmt.Fprintf(&httpsYAML, "tls_cache_dir: \"%s\"\n", tlsCacheDir) + } + } else { + fmt.Fprintf(&httpsYAML, "enable_https: false\n") + } + + return fmt.Sprintf(`listen_addr: ":6001" +client_namespace: "default" +rqlite_dsn: "" +%s +%s +`, peersYAML.String(), httpsYAML.String()) } func createSystemdServices() { @@ -937,6 +1564,10 @@ StandardOutput=journal StandardError=journal SyslogIdentifier=debros-gateway +# Allow binding to privileged ports (80, 443) for HTTPS/ACME +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE + NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=strict diff --git a/pkg/client/defaults.go b/pkg/client/defaults.go index c42f6ae..a12cabb 100644 --- a/pkg/client/defaults.go +++ b/pkg/client/defaults.go @@ -12,6 +12,21 @@ import ( // DefaultBootstrapPeers returns the library's default bootstrap peer multiaddrs. // These can be overridden by environment variables or config. func DefaultBootstrapPeers() []string { + // Check environment variable first + if envPeers := os.Getenv("DEBROS_BOOTSTRAP_PEERS"); envPeers != "" { + peers := splitCSVOrSpace(envPeers) + // Filter out empty strings + result := make([]string, 0, len(peers)) + for _, p := range peers { + if p != "" { + result = append(result, p) + } + } + if len(result) > 0 { + return result + } + } + defaultCfg := config.DefaultConfig() return defaultCfg.Discovery.BootstrapPeers } diff --git a/pkg/client/defaults_test.go b/pkg/client/defaults_test.go index 7bb37a5..eca0d4e 100644 --- a/pkg/client/defaults_test.go +++ b/pkg/client/defaults_test.go @@ -10,11 +10,16 @@ import ( func TestDefaultBootstrapPeersNonEmpty(t *testing.T) { old := os.Getenv("DEBROS_BOOTSTRAP_PEERS") t.Cleanup(func() { os.Setenv("DEBROS_BOOTSTRAP_PEERS", old) }) - _ = os.Setenv("DEBROS_BOOTSTRAP_PEERS", "") // ensure not set + // Set a valid bootstrap peer + validPeer := "/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj" + _ = os.Setenv("DEBROS_BOOTSTRAP_PEERS", validPeer) peers := DefaultBootstrapPeers() if len(peers) == 0 { t.Fatalf("expected non-empty default bootstrap peers") } + if peers[0] != validPeer { + t.Fatalf("expected bootstrap peer %s, got %s", validPeer, peers[0]) + } } func TestDefaultDatabaseEndpointsEnvOverride(t *testing.T) { diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index 33de810..2122e6f 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -5,6 +5,55 @@ import ( "time" ) +// validConfigForType returns a valid config for the given node type +func validConfigForType(nodeType string) *Config { + validPeer := "/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj" + cfg := &Config{ + Node: NodeConfig{ + Type: nodeType, + ID: "test-node-id", + ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, + DataDir: ".", + MaxConnections: 50, + }, + Database: DatabaseConfig{ + DataDir: ".", + ReplicationFactor: 3, + ShardCount: 16, + MaxDatabaseSize: 1024, + BackupInterval: 1 * time.Hour, + RQLitePort: 5001, + RQLiteRaftPort: 7001, + MinClusterSize: 1, + }, + Discovery: DiscoveryConfig{ + BootstrapPeers: []string{validPeer}, + DiscoveryInterval: 15 * time.Second, + BootstrapPort: 4001, + HttpAdvAddress: "127.0.0.1:5001", + RaftAdvAddress: "127.0.0.1:7001", + NodeNamespace: "default", + }, + Logging: LoggingConfig{ + Level: "info", + Format: "console", + }, + } + + // Set rqlite_join_address based on node type + if nodeType == "node" { + cfg.Database.RQLiteJoinAddress = "localhost:5001" + // Node type requires bootstrap peers + cfg.Discovery.BootstrapPeers = []string{validPeer} + } else { + // Bootstrap type: empty join address and peers optional + cfg.Database.RQLiteJoinAddress = "" + cfg.Discovery.BootstrapPeers = []string{} + } + + return cfg +} + func TestValidateNodeType(t *testing.T) { tests := []struct { name string @@ -19,11 +68,11 @@ func TestValidateNodeType(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: tt.nodeType, ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: "console"}, + cfg := validConfigForType("bootstrap") // Start with valid bootstrap + if tt.nodeType == "node" { + cfg = validConfigForType("node") + } else { + cfg.Node.Type = tt.nodeType } errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { @@ -53,12 +102,8 @@ func TestValidateListenAddresses(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: "node", ListenAddresses: tt.addresses, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001, RQLiteJoinAddress: "localhost:7001"}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: "console"}, - } + cfg := validConfigForType("node") + cfg.Node.ListenAddresses = tt.addresses errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -85,12 +130,8 @@ func TestValidateReplicationFactor(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: "node", ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: tt.replication, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001, RQLiteJoinAddress: "localhost:7001"}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: "console"}, - } + cfg := validConfigForType("node") + cfg.Database.ReplicationFactor = tt.replication errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -119,12 +160,9 @@ func TestValidateRQLitePorts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: "node", ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: tt.httpPort, RQLiteRaftPort: tt.raftPort, RQLiteJoinAddress: "localhost:7001"}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: "console"}, - } + cfg := validConfigForType("node") + cfg.Database.RQLitePort = tt.httpPort + cfg.Database.RQLiteRaftPort = tt.raftPort errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -143,9 +181,9 @@ func TestValidateRQLiteJoinAddress(t *testing.T) { joinAddr string shouldError bool }{ - {"node with join", "node", "localhost:7001", false}, + {"node with join", "node", "localhost:5001", false}, {"node without join", "node", "", true}, - {"bootstrap with join", "bootstrap", "localhost:7001", true}, + {"bootstrap with join", "bootstrap", "localhost:5001", true}, {"bootstrap without join", "bootstrap", "", false}, {"invalid join format", "node", "localhost", true}, {"invalid join port", "node", "localhost:99999", true}, @@ -153,12 +191,8 @@ func TestValidateRQLiteJoinAddress(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: tt.nodeType, ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001, RQLiteJoinAddress: tt.joinAddr}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: "console"}, - } + cfg := validConfigForType(tt.nodeType) + cfg.Database.RQLiteJoinAddress = tt.joinAddr errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -190,12 +224,8 @@ func TestValidateBootstrapPeers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: tt.nodeType, ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001, RQLiteJoinAddress: ""}, - Discovery: DiscoveryConfig{BootstrapPeers: tt.peers, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: "console"}, - } + cfg := validConfigForType(tt.nodeType) + cfg.Discovery.BootstrapPeers = tt.peers errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -223,12 +253,8 @@ func TestValidateLoggingLevel(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: "node", ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001, RQLiteJoinAddress: "localhost:7001"}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: tt.level, Format: "console"}, - } + cfg := validConfigForType("node") + cfg.Logging.Level = tt.level errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -254,12 +280,8 @@ func TestValidateLoggingFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: "node", ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001, RQLiteJoinAddress: "localhost:7001"}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: tt.format}, - } + cfg := validConfigForType("node") + cfg.Logging.Format = tt.format errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -285,12 +307,8 @@ func TestValidateMaxConnections(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: "node", ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: tt.maxConn}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001, RQLiteJoinAddress: "localhost:7001"}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: "console"}, - } + cfg := validConfigForType("node") + cfg.Node.MaxConnections = tt.maxConn errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -316,12 +334,8 @@ func TestValidateDiscoveryInterval(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: "node", ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001, RQLiteJoinAddress: "localhost:7001"}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: tt.interval, BootstrapPort: 4001, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: "console"}, - } + cfg := validConfigForType("node") + cfg.Discovery.DiscoveryInterval = tt.interval errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -347,12 +361,8 @@ func TestValidateBootstrapPort(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &Config{ - Node: NodeConfig{Type: "node", ListenAddresses: []string{"/ip4/0.0.0.0/tcp/4001"}, DataDir: ".", MaxConnections: 50}, - Database: DatabaseConfig{DataDir: ".", ReplicationFactor: 3, ShardCount: 16, MaxDatabaseSize: 1024, BackupInterval: 1 * time.Hour, RQLitePort: 5001, RQLiteRaftPort: 7001, RQLiteJoinAddress: "localhost:7001"}, - Discovery: DiscoveryConfig{BootstrapPeers: []string{"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWHbcFcrGPXKUrHcxvd8MXEeUzRYyvY8fQcpEBxncSUwhj"}, DiscoveryInterval: 15 * time.Second, BootstrapPort: tt.port, NodeNamespace: "default"}, - Logging: LoggingConfig{Level: "info", Format: "console"}, - } + cfg := validConfigForType("node") + cfg.Discovery.BootstrapPort = tt.port errs := cfg.Validate() if tt.shouldError && len(errs) == 0 { t.Errorf("expected error, got none") @@ -383,6 +393,7 @@ func TestValidateCompleteConfig(t *testing.T) { RQLitePort: 5002, RQLiteRaftPort: 7002, RQLiteJoinAddress: "127.0.0.1:7001", + MinClusterSize: 1, }, Discovery: DiscoveryConfig{ BootstrapPeers: []string{ @@ -390,7 +401,8 @@ func TestValidateCompleteConfig(t *testing.T) { }, DiscoveryInterval: 15 * time.Second, BootstrapPort: 4001, - HttpAdvAddress: "127.0.0.1", + HttpAdvAddress: "127.0.0.1:5001", + RaftAdvAddress: "127.0.0.1:7001", NodeNamespace: "default", }, Security: SecurityConfig{ diff --git a/pkg/discovery/discovery.go b/pkg/discovery/discovery.go index d022828..aa26f26 100644 --- a/pkg/discovery/discovery.go +++ b/pkg/discovery/discovery.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "strconv" "time" "github.com/libp2p/go-libp2p/core/host" @@ -114,9 +115,41 @@ func (d *Manager) handlePeerExchangeStream(s network.Stream) { continue } + // Filter addresses to only include configured listen addresses, not ephemeral ports + // Ephemeral ports are typically > 32768, so we filter those out + filteredAddrs := make([]multiaddr.Multiaddr, 0) + for _, addr := range addrs { + // Extract TCP port from multiaddr + port, err := addr.ValueForProtocol(multiaddr.P_TCP) + if err == nil { + portNum, err := strconv.Atoi(port) + if err == nil { + // Only include ports that are reasonable (not ephemeral ports > 32768) + // Common LibP2P ports are typically < 10000 + if portNum > 0 && portNum <= 32767 { + filteredAddrs = append(filteredAddrs, addr) + } + } else { + // If we can't parse port, include it anyway (might be non-TCP) + filteredAddrs = append(filteredAddrs, addr) + } + } else { + // If no TCP port found, include it anyway (might be non-TCP) + filteredAddrs = append(filteredAddrs, addr) + } + } + + // If no addresses remain after filtering, skip this peer + if len(filteredAddrs) == 0 { + d.logger.Debug("No valid addresses after filtering ephemeral ports", + zap.String("peer_id", pid.String()[:8]+"..."), + zap.Int("original_count", len(addrs))) + continue + } + // Convert addresses to strings - addrStrs := make([]string, len(addrs)) - for i, addr := range addrs { + addrStrs := make([]string, len(filteredAddrs)) + for i, addr := range filteredAddrs { addrStrs[i] = addr.String() } @@ -320,7 +353,7 @@ func (d *Manager) discoverViaPeerExchange(ctx context.Context, maxConnections in d.host.Peerstore().AddAddrs(parsedID, addrs, time.Hour*24) // Try to connect - connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second) peerAddrInfo := peer.AddrInfo{ID: parsedID, Addrs: addrs} if err := d.host.Connect(connectCtx, peerAddrInfo); err != nil { diff --git a/pkg/gateway/anon_proxy_handler.go b/pkg/gateway/anon_proxy_handler.go index 2b27cfb..e8aa925 100644 --- a/pkg/gateway/anon_proxy_handler.go +++ b/pkg/gateway/anon_proxy_handler.go @@ -206,9 +206,31 @@ func isHopByHopHeader(header string) bool { // isPrivateOrLocalHost checks if a host is private, local, or loopback func isPrivateOrLocalHost(host string) bool { - // Strip port if present - if idx := strings.LastIndex(host, ":"); idx != -1 { - host = host[:idx] + // Strip port if present, handling IPv6 addresses properly + // IPv6 addresses in URLs are bracketed: [::1]:8080 + if strings.HasPrefix(host, "[") { + // IPv6 address with brackets + if idx := strings.LastIndex(host, "]"); idx != -1 { + if idx+1 < len(host) && host[idx+1] == ':' { + // Port present, strip it + host = host[1:idx] // Remove brackets and port + } else { + // No port, just remove brackets + host = host[1:idx] + } + } + } else { + // IPv4 or hostname, check for port + if idx := strings.LastIndex(host, ":"); idx != -1 { + // Check if it's an IPv6 address without brackets (contains multiple colons) + colonCount := strings.Count(host, ":") + if colonCount == 1 { + // Single colon, likely IPv4 with port + host = host[:idx] + } + // If multiple colons, it's IPv6 without brackets and no port + // Leave host as-is + } } // Check for localhost variants diff --git a/pkg/gateway/config_validate.go b/pkg/gateway/config_validate.go index a185107..baae7be 100644 --- a/pkg/gateway/config_validate.go +++ b/pkg/gateway/config_validate.go @@ -70,6 +70,21 @@ func (c *Config) ValidateConfig() []error { } } + // Validate HTTPS configuration + if c.EnableHTTPS { + if c.DomainName == "" { + errs = append(errs, fmt.Errorf("gateway.domain_name: must be set when enable_https is true")) + } else { + // Basic domain validation + if !isValidDomainName(c.DomainName) { + errs = append(errs, fmt.Errorf("gateway.domain_name: invalid domain format")) + } + } + if c.TLSCacheDir == "" { + errs = append(errs, fmt.Errorf("gateway.tls_cache_dir: must be set when enable_https is true")) + } + } + return errs } @@ -135,3 +150,38 @@ func extractTCPPort(multiaddrStr string) string { return portPart[:firstSlashIndex] } + +// isValidDomainName validates a domain name format +func isValidDomainName(domain string) bool { + domain = strings.TrimSpace(domain) + if domain == "" { + return false + } + + // Basic validation: domain should contain at least one dot + // and not start/end with dot or hyphen + if !strings.Contains(domain, ".") { + return false + } + + if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") { + return false + } + + if strings.HasPrefix(domain, "-") || strings.HasSuffix(domain, "-") { + return false + } + + // Check for valid characters (letters, numbers, dots, hyphens) + for _, char := range domain { + if !((char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '.' || + char == '-') { + return false + } + } + + return true +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 6057a34..62354cb 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -26,6 +26,11 @@ type Config struct { // Optional DSN for rqlite database/sql driver, e.g. "http://localhost:4001" // If empty, defaults to "http://localhost:4001". RQLiteDSN string + + // HTTPS configuration + EnableHTTPS bool // Enable HTTPS with ACME (Let's Encrypt) + DomainName string // Domain name for HTTPS certificate + TLSCacheDir string // Directory to cache TLS certificates (default: ~/.debros/tls-cache) } type Gateway struct { diff --git a/pkg/node/node.go b/pkg/node/node.go index 7aa7824..af840e8 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -328,7 +328,6 @@ func (n *Node) startLibP2P() error { n.logger.ComponentInfo(logging.ComponentLibP2P, "Localhost detected - disabling NAT services for local development") // Don't add NAT/AutoRelay options for localhost } else { - // Production: enable NAT traversal n.logger.ComponentInfo(logging.ComponentLibP2P, "Production mode - enabling NAT services") opts = append(opts, libp2p.EnableNATService(), diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..51a7156 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# Install git hooks from .githooks/ to .git/hooks/ +# This ensures the pre-push hook runs automatically + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +GITHOOKS_DIR="$REPO_ROOT/.githooks" +GIT_HOOKS_DIR="$REPO_ROOT/.git/hooks" + +if [ ! -d "$GITHOOKS_DIR" ]; then + echo "Error: .githooks directory not found at $GITHOOKS_DIR" + exit 1 +fi + +if [ ! -d "$GIT_HOOKS_DIR" ]; then + echo "Error: .git/hooks directory not found at $GIT_HOOKS_DIR" + echo "Are you in a git repository?" + exit 1 +fi + +echo "Installing git hooks..." + +# Copy all hooks from .githooks/ to .git/hooks/ +for hook in "$GITHOOKS_DIR"/*; do + if [ -f "$hook" ]; then + hook_name=$(basename "$hook") + dest="$GIT_HOOKS_DIR/$hook_name" + + echo " Installing $hook_name..." + cp "$hook" "$dest" + chmod +x "$dest" + + # Make sure the hook can find the repo root + # The hooks already use relative paths, so this should work + fi +done + +echo "✓ Git hooks installed successfully!" +echo "" +echo "The following hooks are now active:" +ls -1 "$GIT_HOOKS_DIR"/* 2>/dev/null | xargs -n1 basename || echo " (none)" + diff --git a/scripts/update_changelog.sh b/scripts/update_changelog.sh new file mode 100755 index 0000000..1f10e1d --- /dev/null +++ b/scripts/update_changelog.sh @@ -0,0 +1,426 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NOCOLOR='\033[0m' + +log() { echo -e "${CYAN}[update-changelog]${NOCOLOR} $1"; } +error() { echo -e "${RED}[ERROR]${NOCOLOR} $1"; } +success() { echo -e "${GREEN}[SUCCESS]${NOCOLOR} $1"; } +warning() { echo -e "${YELLOW}[WARNING]${NOCOLOR} $1"; } + +# File paths +CHANGELOG_FILE="CHANGELOG.md" +MAKEFILE="Makefile" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$REPO_ROOT" + +# Load environment variables from .env file if it exists +if [ -f "$REPO_ROOT/.env" ]; then + # Export variables from .env file (more portable than source <()) + set -a + while IFS='=' read -r key value; do + # Skip comments and empty lines + [[ "$key" =~ ^#.*$ ]] && continue + [[ -z "$key" ]] && continue + # Remove quotes if present + value=$(echo "$value" | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//") + export "$key=$value" + done < "$REPO_ROOT/.env" + set +a +fi + +# OpenRouter API key +# Priority: 1. Environment variable, 2. .env file, 3. Exit with error +if [ -z "$OPENROUTER_API_KEY" ]; then + error "OPENROUTER_API_KEY not found!" + echo "" + echo "Please set the API key in one of these ways:" + echo " 1. Create a .env file in the repo root with:" + echo " OPENROUTER_API_KEY=your-api-key-here" + echo "" + echo " 2. Set it as an environment variable:" + echo " export OPENROUTER_API_KEY=your-api-key-here" + echo "" + echo " 3. Copy .env.example to .env and fill in your key:" + echo " cp .env.example .env" + echo "" + echo "Get your API key from: https://openrouter.ai/keys" + exit 1 +fi + +# Check dependencies +if ! command -v jq > /dev/null 2>&1; then + error "jq is required but not installed. Install it with: brew install jq (macOS) or apt-get install jq (Linux)" + exit 1 +fi + +if ! command -v curl > /dev/null 2>&1; then + error "curl is required but not installed" + exit 1 +fi + +# Check if we're in a git repo +if ! git rev-parse --git-dir > /dev/null 2>&1; then + error "Not in a git repository" + exit 1 +fi + +# Get current branch +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +REMOTE_BRANCH="origin/$CURRENT_BRANCH" + +# Check if remote branch exists +if ! git rev-parse --verify "$REMOTE_BRANCH" > /dev/null 2>&1; then + warning "Remote branch $REMOTE_BRANCH does not exist. Using main/master as baseline." + if git rev-parse --verify "origin/main" > /dev/null 2>&1; then + REMOTE_BRANCH="origin/main" + elif git rev-parse --verify "origin/master" > /dev/null 2>&1; then + REMOTE_BRANCH="origin/master" + else + warning "No remote branch found. Using HEAD as baseline." + REMOTE_BRANCH="HEAD" + fi +fi + +# Gather all git diffs +log "Collecting git diffs..." + +# Check if running from pre-commit context +if [ "$CHANGELOG_CONTEXT" = "pre-commit" ]; then + log "Running in pre-commit context - analyzing staged changes only" + + # Unstaged changes (usually none in pre-commit, but check anyway) + UNSTAGED_DIFF=$(git diff 2>/dev/null || echo "") + UNSTAGED_COUNT=$(echo "$UNSTAGED_DIFF" | grep -c "^diff\|^index" 2>/dev/null || echo "0") + [ -z "$UNSTAGED_COUNT" ] && UNSTAGED_COUNT="0" + + # Staged changes (these are what we're committing) + STAGED_DIFF=$(git diff --cached 2>/dev/null || echo "") + STAGED_COUNT=$(echo "$STAGED_DIFF" | grep -c "^diff\|^index" 2>/dev/null || echo "0") + [ -z "$STAGED_COUNT" ] && STAGED_COUNT="0" + + # No unpushed commits analysis in pre-commit context + UNPUSHED_DIFF="" + UNPUSHED_COMMITS="0" + + log "Found: $UNSTAGED_COUNT unstaged file(s), $STAGED_COUNT staged file(s)" +else + # Pre-push context - analyze everything + # Unstaged changes + UNSTAGED_DIFF=$(git diff 2>/dev/null || echo "") + UNSTAGED_COUNT=$(echo "$UNSTAGED_DIFF" | grep -c "^diff\|^index" 2>/dev/null || echo "0") + [ -z "$UNSTAGED_COUNT" ] && UNSTAGED_COUNT="0" + + # Staged changes + STAGED_DIFF=$(git diff --cached 2>/dev/null || echo "") + STAGED_COUNT=$(echo "$STAGED_DIFF" | grep -c "^diff\|^index" 2>/dev/null || echo "0") + [ -z "$STAGED_COUNT" ] && STAGED_COUNT="0" + + # Unpushed commits + UNPUSHED_DIFF=$(git diff "$REMOTE_BRANCH"..HEAD 2>/dev/null || echo "") + UNPUSHED_COMMITS=$(git rev-list --count "$REMOTE_BRANCH"..HEAD 2>/dev/null || echo "0") + [ -z "$UNPUSHED_COMMITS" ] && UNPUSHED_COMMITS="0" + + # Check if the only unpushed commit is a changelog update commit + # If so, exclude it from the diff to avoid infinite loops + if [ "$UNPUSHED_COMMITS" -gt 0 ]; then + LATEST_COMMIT_MSG=$(git log -1 --pretty=%B HEAD 2>/dev/null || echo "") + if echo "$LATEST_COMMIT_MSG" | grep -q "chore: update changelog and version"; then + # If the latest commit is a changelog commit, check if there are other commits + if [ "$UNPUSHED_COMMITS" -eq 1 ]; then + log "Latest commit is a changelog update. No other changes detected. Skipping changelog update." + # Clean up any old preview files + rm -f "$REPO_ROOT/.changelog_preview.tmp" "$REPO_ROOT/.changelog_version.tmp" + exit 0 + else + # Multiple commits, exclude the latest changelog commit from diff + log "Multiple unpushed commits detected. Excluding latest changelog commit from analysis." + # Get all commits except the latest one + UNPUSHED_DIFF=$(git diff "$REMOTE_BRANCH"..HEAD~1 2>/dev/null || echo "") + UNPUSHED_COMMITS=$(git rev-list --count "$REMOTE_BRANCH"..HEAD~1 2>/dev/null || echo "0") + [ -z "$UNPUSHED_COMMITS" ] && UNPUSHED_COMMITS="0" + fi + fi + fi + + log "Found: $UNSTAGED_COUNT unstaged file(s), $STAGED_COUNT staged file(s), $UNPUSHED_COMMITS unpushed commit(s)" +fi + +# Combine all diffs +if [ "$CHANGELOG_CONTEXT" = "pre-commit" ]; then + ALL_DIFFS="${UNSTAGED_DIFF} +--- +STAGED CHANGES: +--- +${STAGED_DIFF}" +else + ALL_DIFFS="${UNSTAGED_DIFF} +--- +STAGED CHANGES: +--- +${STAGED_DIFF} +--- +UNPUSHED COMMITS: +--- +${UNPUSHED_DIFF}" +fi + +# Check if there are any changes +if [ "$CHANGELOG_CONTEXT" = "pre-commit" ]; then + # In pre-commit, only check staged changes + if [ -z "$(echo "$STAGED_DIFF" | tr -d '[:space:]')" ]; then + log "No staged changes detected. Skipping changelog update." + rm -f "$REPO_ROOT/.changelog_preview.tmp" "$REPO_ROOT/.changelog_version.tmp" + exit 0 + fi +else + # In pre-push, check all changes + if [ -z "$(echo "$UNSTAGED_DIFF$STAGED_DIFF$UNPUSHED_DIFF" | tr -d '[:space:]')" ]; then + log "No changes detected (unstaged, staged, or unpushed). Skipping changelog update." + rm -f "$REPO_ROOT/.changelog_preview.tmp" "$REPO_ROOT/.changelog_version.tmp" + exit 0 + fi +fi + +# Get current version from Makefile +CURRENT_VERSION=$(grep "^VERSION :=" "$MAKEFILE" | sed 's/.*:= *//' | tr -d ' ') + +if [ -z "$CURRENT_VERSION" ]; then + error "Could not find VERSION in Makefile" + exit 1 +fi + +log "Current version: $CURRENT_VERSION" + +# Get today's date programmatically (YYYY-MM-DD format) +TODAY_DATE=$(date +%Y-%m-%d) +log "Using date: $TODAY_DATE" + +# Prepare prompt for OpenRouter +PROMPT="You are analyzing git diffs to create a changelog entry. Based on the following git diffs, create a simple, easy-to-understand changelog entry. + +Current version: $CURRENT_VERSION + +Git diffs: +\`\`\` +$ALL_DIFFS +\`\`\` + +Please respond with ONLY a valid JSON object in this exact format: +{ + \"version\": \"x.y.z\", + \"bump_type\": \"minor\" or \"patch\", + \"added\": [\"item1\", \"item2\"], + \"changed\": [\"item1\", \"item2\"], + \"fixed\": [\"item1\", \"item2\"] +} + +Rules: +- Bump version based on changes: use \"minor\" for new features, \"patch\" for bug fixes and small changes +- Never bump major version (keep major version the same) +- Keep descriptions simple and easy to understand (1-2 sentences max per item) +- Only include items that actually changed +- If a category is empty, use an empty array [] +- Do NOT include a date field - the date will be set programmatically" + +# Call OpenRouter API +log "Calling OpenRouter API to generate changelog..." + +# Prepare the JSON payload properly +PROMPT_ESCAPED=$(echo "$PROMPT" | jq -Rs .) +REQUEST_BODY=$(cat < /dev/null 2>&1; then + error "OpenRouter API error:" + ERROR_MESSAGE=$(echo "$RESPONSE_BODY" | jq -r '.error.message // .error' 2>/dev/null || echo "$RESPONSE_BODY") + echo "$ERROR_MESSAGE" + echo "" + error "Full API response:" + echo "$RESPONSE_BODY" | jq '.' 2>/dev/null || echo "$RESPONSE_BODY" + echo "" + error "The API key may be invalid or expired. Please verify your OpenRouter API key at https://openrouter.ai/keys" + echo "" + error "To test your API key manually, run:" + echo " curl https://openrouter.ai/api/v1/chat/completions \\" + echo " -H \"Content-Type: application/json\" \\" + echo " -H \"Authorization: Bearer YOUR_API_KEY\" \\" + echo " -d '{\"model\": \"google/gemini-2.5-flash-preview-09-2025\", \"messages\": [{\"role\": \"user\", \"content\": \"test\"}]}'" + exit 1 +fi + +# Extract JSON from response +JSON_CONTENT=$(echo "$RESPONSE_BODY" | jq -r '.choices[0].message.content' 2>/dev/null) + +# Check if content was extracted +if [ -z "$JSON_CONTENT" ] || [ "$JSON_CONTENT" = "null" ]; then + error "Failed to extract content from API response" + echo "Response: $RESPONSE_BODY" + exit 1 +fi + +# Try to extract JSON if it's wrapped in markdown code blocks +if echo "$JSON_CONTENT" | grep -q '```json'; then + JSON_CONTENT=$(echo "$JSON_CONTENT" | sed -n '/```json/,/```/p' | sed '1d;$d') +elif echo "$JSON_CONTENT" | grep -q '```'; then + JSON_CONTENT=$(echo "$JSON_CONTENT" | sed -n '/```/,/```/p' | sed '1d;$d') +fi + +# Validate JSON +if ! echo "$JSON_CONTENT" | jq . > /dev/null 2>&1; then + error "Invalid JSON response from API:" + echo "$JSON_CONTENT" + exit 1 +fi + +# Parse JSON +NEW_VERSION=$(echo "$JSON_CONTENT" | jq -r '.version') +BUMP_TYPE=$(echo "$JSON_CONTENT" | jq -r '.bump_type') +ADDED=$(echo "$JSON_CONTENT" | jq -r '.added[]?' | sed 's/^/- /') +CHANGED=$(echo "$JSON_CONTENT" | jq -r '.changed[]?' | sed 's/^/- /') +FIXED=$(echo "$JSON_CONTENT" | jq -r '.fixed[]?' | sed 's/^/- /') + +log "Generated version: $NEW_VERSION ($BUMP_TYPE bump)" +log "Date: $TODAY_DATE" + +# Validate version format +if ! echo "$NEW_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + error "Invalid version format: $NEW_VERSION" + exit 1 +fi + +# Validate bump type +if [ "$BUMP_TYPE" != "minor" ] && [ "$BUMP_TYPE" != "patch" ]; then + error "Invalid bump type: $BUMP_TYPE (must be 'minor' or 'patch')" + exit 1 +fi + +# Update Makefile +log "Updating Makefile..." +if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS sed requires backup extension + sed -i '' "s/^VERSION := .*/VERSION := $NEW_VERSION/" "$MAKEFILE" +else + # Linux sed + sed -i "s/^VERSION := .*/VERSION := $NEW_VERSION/" "$MAKEFILE" +fi +success "Makefile updated to version $NEW_VERSION" + +# Update CHANGELOG.md +log "Updating CHANGELOG.md..." + +# Create changelog entry +CHANGELOG_ENTRY="## [$NEW_VERSION] - $TODAY_DATE + +### Added +" +if [ -n "$ADDED" ]; then + CHANGELOG_ENTRY+="$ADDED"$'\n' +else + CHANGELOG_ENTRY+="\n" +fi + +CHANGELOG_ENTRY+=" +### Changed +" +if [ -n "$CHANGED" ]; then + CHANGELOG_ENTRY+="$CHANGED"$'\n' +else + CHANGELOG_ENTRY+="\n" +fi + +CHANGELOG_ENTRY+=" +### Deprecated + +### Removed + +### Fixed +" +if [ -n "$FIXED" ]; then + CHANGELOG_ENTRY+="$FIXED"$'\n' +else + CHANGELOG_ENTRY+="\n" +fi + +CHANGELOG_ENTRY+=" +" + +# Save preview to temp file for pre-push hook +PREVIEW_FILE="$REPO_ROOT/.changelog_preview.tmp" +echo "$CHANGELOG_ENTRY" > "$PREVIEW_FILE" +echo "$NEW_VERSION" > "$REPO_ROOT/.changelog_version.tmp" + +# Insert after [Unreleased] section using awk (more portable) +# Find the line number after [Unreleased] section (after the "### Fixed" line) +INSERT_LINE=$(awk '/^## \[Unreleased\]/{found=1} found && /^### Fixed$/{print NR+1; exit}' "$CHANGELOG_FILE") + +if [ -z "$INSERT_LINE" ]; then + # Fallback: insert after line 16 (after [Unreleased] section) + INSERT_LINE=16 +fi + +# Use a temp file approach to insert multiline content +TMP_FILE=$(mktemp) +{ + head -n $((INSERT_LINE - 1)) "$CHANGELOG_FILE" + printf '%s' "$CHANGELOG_ENTRY" + tail -n +$INSERT_LINE "$CHANGELOG_FILE" +} > "$TMP_FILE" +mv "$TMP_FILE" "$CHANGELOG_FILE" + +success "CHANGELOG.md updated with version $NEW_VERSION" + +log "Changelog update complete!" +log "New version: $NEW_VERSION" +log "Bump type: $BUMP_TYPE" +