diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..91cca1c --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,6 @@ +package migrations + +import "embed" + +//go:embed *.sql +var FS embed.FS diff --git a/pkg/config/gateway_config.go b/pkg/config/gateway_config.go index e64d745..d277be9 100644 --- a/pkg/config/gateway_config.go +++ b/pkg/config/gateway_config.go @@ -19,7 +19,7 @@ type HTTPGatewayConfig struct { IPFSClusterAPIURL string `yaml:"ipfs_cluster_api_url"` // IPFS Cluster API URL IPFSAPIURL string `yaml:"ipfs_api_url"` // IPFS API URL IPFSTimeout time.Duration `yaml:"ipfs_timeout"` // Timeout for IPFS operations - BaseDomain string `yaml:"base_domain"` // Base domain for deployments (e.g., "dbrs.space", defaults to "orama.network") + BaseDomain string `yaml:"base_domain"` // Base domain for deployments (e.g., "dbrs.space"). Defaults to "dbrs.space" } // HTTPSConfig contains HTTPS/TLS configuration for the gateway diff --git a/pkg/deployments/process/manager.go b/pkg/deployments/process/manager.go index 468b931..1e05802 100644 --- a/pkg/deployments/process/manager.go +++ b/pkg/deployments/process/manager.go @@ -237,7 +237,8 @@ WantedBy=multi-user.target func (m *Manager) getStartCommand(deployment *deployments.Deployment, workDir string) string { switch deployment.Type { case deployments.DeploymentTypeNextJS: - return "/usr/bin/node server.js" + // Next.js standalone output places server at .next/standalone/server.js + return "/usr/bin/node .next/standalone/server.js" case deployments.DeploymentTypeNodeJSBackend: // Check if ENTRY_POINT is set in environment if entryPoint, ok := deployment.Environment["ENTRY_POINT"]; ok { diff --git a/pkg/environments/production/orchestrator.go b/pkg/environments/production/orchestrator.go index f7f5554..fb625f1 100644 --- a/pkg/environments/production/orchestrator.go +++ b/pkg/environments/production/orchestrator.go @@ -211,6 +211,13 @@ func (ps *ProductionSetup) Phase2ProvisionEnvironment() error { } } + // Set up deployment sudoers (allows debros user to manage orama-deploy-* services) + if err := ps.userProvisioner.SetupDeploymentSudoers(); err != nil { + ps.logf(" ⚠️ Failed to setup deployment sudoers: %v", err) + } else { + ps.logf(" ✓ Deployment sudoers configured") + } + // Create directory structure (unified structure) if err := ps.fsProvisioner.EnsureDirectoryStructure(); err != nil { return fmt.Errorf("failed to create directory structure: %w", err) diff --git a/pkg/environments/production/provisioner.go b/pkg/environments/production/provisioner.go index d095dbe..8d90f65 100644 --- a/pkg/environments/production/provisioner.go +++ b/pkg/environments/production/provisioner.go @@ -182,6 +182,48 @@ func (up *UserProvisioner) SetupSudoersAccess(invokerUser string) error { return nil } +// SetupDeploymentSudoers configures the debros user with permissions needed for +// managing user deployments via systemd services. +func (up *UserProvisioner) SetupDeploymentSudoers() error { + sudoersFile := "/etc/sudoers.d/debros-deployments" + + // Check if already configured + if _, err := os.Stat(sudoersFile); err == nil { + return nil // Already configured + } + + sudoersContent := `# DeBros Network - Deployment Management Permissions +# Allows debros user to manage systemd services for user deployments + +# Systemd service management for orama-deploy-* services +debros ALL=(ALL) NOPASSWD: /usr/bin/systemctl daemon-reload +debros ALL=(ALL) NOPASSWD: /usr/bin/systemctl start orama-deploy-* +debros ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop orama-deploy-* +debros ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart orama-deploy-* +debros ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable orama-deploy-* +debros ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable orama-deploy-* +debros ALL=(ALL) NOPASSWD: /usr/bin/systemctl status orama-deploy-* + +# Service file management (tee to write, rm to remove) +debros ALL=(ALL) NOPASSWD: /usr/bin/tee /etc/systemd/system/orama-deploy-*.service +debros ALL=(ALL) NOPASSWD: /bin/rm -f /etc/systemd/system/orama-deploy-*.service +` + + // Write sudoers rule + if err := os.WriteFile(sudoersFile, []byte(sudoersContent), 0440); err != nil { + return fmt.Errorf("failed to create deployment sudoers rule: %w", err) + } + + // Validate sudoers file + cmd := exec.Command("visudo", "-c", "-f", sudoersFile) + if err := cmd.Run(); err != nil { + os.Remove(sudoersFile) // Clean up on validation failure + return fmt.Errorf("deployment sudoers rule validation failed: %w", err) + } + + return nil +} + // StateDetector checks for existing production state type StateDetector struct { oramaDir string diff --git a/pkg/environments/production/services.go b/pkg/environments/production/services.go index 7ae6eba..1cd6ca8 100644 --- a/pkg/environments/production/services.go +++ b/pkg/environments/production/services.go @@ -231,18 +231,13 @@ StandardOutput=append:%[4]s StandardError=append:%[4]s SyslogIdentifier=debros-node -AmbientCapabilities=CAP_NET_BIND_SERVICE -CapabilityBoundingSet=CAP_NET_BIND_SERVICE - PrivateTmp=yes -ProtectSystem=strict ProtectHome=read-only ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes RestrictRealtime=yes -RestrictSUIDSGID=yes -ReadWritePaths=%[2]s +ReadWritePaths=%[2]s /etc/systemd/system [Install] WantedBy=multi-user.target diff --git a/pkg/gateway/config.go b/pkg/gateway/config.go index 1ff94e4..ac76609 100644 --- a/pkg/gateway/config.go +++ b/pkg/gateway/config.go @@ -19,7 +19,7 @@ type Config struct { TLSCacheDir string // Directory to cache TLS certificates (default: ~/.orama/tls-cache) // Domain routing configuration - BaseDomain string // Base domain for deployment routing (e.g., "dbrs.space"). Defaults to "orama.network" + BaseDomain string // Base domain for deployment routing. Set via node config http_gateway.base_domain. Defaults to "dbrs.space" // Data directory configuration DataDir string // Base directory for node-local data (SQLite databases, deployments). Defaults to ~/.orama diff --git a/pkg/gateway/handlers/deployments/go_handler.go b/pkg/gateway/handlers/deployments/go_handler.go index 20d573b..6f8173d 100644 --- a/pkg/gateway/handlers/deployments/go_handler.go +++ b/pkg/gateway/handlers/deployments/go_handler.go @@ -106,8 +106,8 @@ func (h *GoHandler) HandleUpload(w http.ResponseWriter, r *http.Request) { return } - // Create DNS records - go h.service.CreateDNSRecords(ctx, deployment) + // Create DNS records (use background context since HTTP context will be cancelled) + go h.service.CreateDNSRecords(context.Background(), deployment) // Build response urls := h.service.BuildDeploymentURLs(deployment) diff --git a/pkg/gateway/handlers/deployments/list_handler.go b/pkg/gateway/handlers/deployments/list_handler.go index 2ae1f80..73978fb 100644 --- a/pkg/gateway/handlers/deployments/list_handler.go +++ b/pkg/gateway/handlers/deployments/list_handler.go @@ -65,8 +65,9 @@ func (h *ListHandler) HandleList(w http.ResponseWriter, r *http.Request) { baseDomain := h.service.BaseDomain() deployments := make([]map[string]interface{}, len(rows)) for i, row := range rows { + shortNodeID := GetShortNodeID(row.HomeNodeID) urls := []string{ - "https://" + row.Name + "." + row.HomeNodeID + "." + baseDomain, + "https://" + row.Name + "." + shortNodeID + "." + baseDomain, } if row.Subdomain != "" { urls = append(urls, "https://"+row.Subdomain+"."+baseDomain) diff --git a/pkg/gateway/handlers/deployments/nextjs_handler.go b/pkg/gateway/handlers/deployments/nextjs_handler.go index 70e7d30..6140374 100644 --- a/pkg/gateway/handlers/deployments/nextjs_handler.go +++ b/pkg/gateway/handlers/deployments/nextjs_handler.go @@ -110,8 +110,8 @@ func (h *NextJSHandler) HandleUpload(w http.ResponseWriter, r *http.Request) { return } - // Create DNS records - go h.service.CreateDNSRecords(ctx, deployment) + // Create DNS records (use background context since HTTP context will be cancelled) + go h.service.CreateDNSRecords(context.Background(), deployment) // Build response urls := h.service.BuildDeploymentURLs(deployment) @@ -186,6 +186,12 @@ func (h *NextJSHandler) deploySSR(ctx context.Context, namespace, name, subdomai } deployment.Status = deployments.DeploymentStatusActive + + // Update status in database + if err := h.service.UpdateDeploymentStatus(ctx, deployment.ID, deployment.Status); err != nil { + h.logger.Warn("Failed to update deployment status", zap.Error(err)) + } + return deployment, nil } diff --git a/pkg/gateway/handlers/deployments/nodejs_handler.go b/pkg/gateway/handlers/deployments/nodejs_handler.go index dfa1f79..c6527fb 100644 --- a/pkg/gateway/handlers/deployments/nodejs_handler.go +++ b/pkg/gateway/handlers/deployments/nodejs_handler.go @@ -107,8 +107,8 @@ func (h *NodeJSHandler) HandleUpload(w http.ResponseWriter, r *http.Request) { return } - // Create DNS records - go h.service.CreateDNSRecords(ctx, deployment) + // Create DNS records (use background context since HTTP context will be cancelled) + go h.service.CreateDNSRecords(context.Background(), deployment) // Build response urls := h.service.BuildDeploymentURLs(deployment) diff --git a/pkg/gateway/handlers/deployments/service.go b/pkg/gateway/handlers/deployments/service.go index fb6dfb2..27aa7c1 100644 --- a/pkg/gateway/handlers/deployments/service.go +++ b/pkg/gateway/handlers/deployments/service.go @@ -33,7 +33,7 @@ func NewDeploymentService( homeNodeManager: homeNodeManager, portAllocator: portAllocator, logger: logger, - baseDomain: "orama.network", // default + baseDomain: "dbrs.space", // default } } @@ -47,11 +47,26 @@ func (s *DeploymentService) SetBaseDomain(domain string) { // BaseDomain returns the configured base domain func (s *DeploymentService) BaseDomain() string { if s.baseDomain == "" { - return "orama.network" + return "dbrs.space" } return s.baseDomain } +// GetShortNodeID extracts a short node ID from a full peer ID for domain naming. +// e.g., "12D3KooWGqyuQR8N..." -> "node-GqyuQR" +// If the ID is already short (starts with "node-"), returns it as-is. +func GetShortNodeID(peerID string) string { + // If already a short ID, return as-is + if len(peerID) < 20 { + return peerID + } + // Skip "12D3KooW" prefix (8 chars) and take next 6 chars + if len(peerID) > 14 { + return "node-" + peerID[8:14] + } + return "node-" + peerID[:6] +} + // CreateDeployment creates a new deployment func (s *DeploymentService) CreateDeployment(ctx context.Context, deployment *deployments.Deployment) error { // Assign home node if not already assigned @@ -273,24 +288,31 @@ func (s *DeploymentService) UpdateDeploymentStatus(ctx context.Context, deployme // CreateDNSRecords creates DNS records for a deployment func (s *DeploymentService) CreateDNSRecords(ctx context.Context, deployment *deployments.Deployment) error { - // Get node IP + // Get node IP using the full node ID nodeIP, err := s.getNodeIP(ctx, deployment.HomeNodeID) if err != nil { s.logger.Error("Failed to get node IP", zap.Error(err)) return err } - // Create node-specific record - nodeFQDN := fmt.Sprintf("%s.%s.%s.", deployment.Name, deployment.HomeNodeID, s.BaseDomain()) + // Use short node ID for the domain (e.g., node-kv4la8 instead of full peer ID) + shortNodeID := GetShortNodeID(deployment.HomeNodeID) + + // Create node-specific record: {name}.node-{shortID}.{baseDomain} + nodeFQDN := fmt.Sprintf("%s.%s.%s.", deployment.Name, shortNodeID, s.BaseDomain()) if err := s.createDNSRecord(ctx, nodeFQDN, "A", nodeIP, deployment.Namespace, deployment.ID); err != nil { s.logger.Error("Failed to create node-specific DNS record", zap.Error(err)) + } else { + s.logger.Info("Created node-specific DNS record", zap.String("fqdn", nodeFQDN), zap.String("ip", nodeIP)) } - // Create load-balanced record if subdomain is set + // Create load-balanced record if subdomain is set: {subdomain}.{baseDomain} if deployment.Subdomain != "" { lbFQDN := fmt.Sprintf("%s.%s.", deployment.Subdomain, s.BaseDomain()) if err := s.createDNSRecord(ctx, lbFQDN, "A", nodeIP, deployment.Namespace, deployment.ID); err != nil { s.logger.Error("Failed to create load-balanced DNS record", zap.Error(err)) + } else { + s.logger.Info("Created load-balanced DNS record", zap.String("fqdn", lbFQDN), zap.String("ip", nodeIP)) } } @@ -310,30 +332,47 @@ func (s *DeploymentService) createDNSRecord(ctx context.Context, fqdn, recordTyp return err } -// getNodeIP retrieves the IP address for a node +// getNodeIP retrieves the IP address for a node. +// It tries to find the node by full peer ID first, then by short node ID. func (s *DeploymentService) getNodeIP(ctx context.Context, nodeID string) (string, error) { type nodeRow struct { IPAddress string `db:"ip_address"` } var rows []nodeRow + + // Try full node ID first query := `SELECT ip_address FROM dns_nodes WHERE id = ? LIMIT 1` err := s.db.Query(ctx, &rows, query, nodeID) if err != nil { return "", err } - if len(rows) == 0 { - return "", fmt.Errorf("node not found: %s", nodeID) + // If found, return it + if len(rows) > 0 { + return rows[0].IPAddress, nil } - return rows[0].IPAddress, nil + // Try with short node ID if the original was a full peer ID + shortID := GetShortNodeID(nodeID) + if shortID != nodeID { + err = s.db.Query(ctx, &rows, query, shortID) + if err != nil { + return "", err + } + if len(rows) > 0 { + return rows[0].IPAddress, nil + } + } + + return "", fmt.Errorf("node not found: %s (tried: %s, %s)", nodeID, nodeID, shortID) } // BuildDeploymentURLs builds all URLs for a deployment func (s *DeploymentService) BuildDeploymentURLs(deployment *deployments.Deployment) []string { + shortNodeID := GetShortNodeID(deployment.HomeNodeID) urls := []string{ - fmt.Sprintf("https://%s.%s.%s", deployment.Name, deployment.HomeNodeID, s.BaseDomain()), + fmt.Sprintf("https://%s.%s.%s", deployment.Name, shortNodeID, s.BaseDomain()), } if deployment.Subdomain != "" { diff --git a/pkg/gateway/handlers/deployments/static_handler.go b/pkg/gateway/handlers/deployments/static_handler.go index a57b5cd..dde85b3 100644 --- a/pkg/gateway/handlers/deployments/static_handler.go +++ b/pkg/gateway/handlers/deployments/static_handler.go @@ -154,8 +154,8 @@ func (h *StaticDeploymentHandler) HandleUpload(w http.ResponseWriter, r *http.Re return } - // Create DNS records - go h.service.CreateDNSRecords(ctx, deployment) + // Create DNS records (use background context since HTTP context will be cancelled) + go h.service.CreateDNSRecords(context.Background(), deployment) // Build URLs urls := h.service.BuildDeploymentURLs(deployment) diff --git a/pkg/gateway/middleware.go b/pkg/gateway/middleware.go index 418f601..ede242c 100644 --- a/pkg/gateway/middleware.go +++ b/pkg/gateway/middleware.go @@ -2,6 +2,7 @@ package gateway import ( "context" + "crypto/tls" "encoding/json" "io" "net" @@ -198,7 +199,7 @@ func isPublicPath(p string) bool { } switch p { - case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/login", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key", "/v1/auth/simple-key", "/v1/network/status", "/v1/network/peers": + case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/login", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key", "/v1/auth/simple-key", "/v1/network/status", "/v1/network/peers", "/v1/internal/tls/check": return true default: return false @@ -491,6 +492,10 @@ func (g *Gateway) domainRoutingMiddleware(next http.Handler) http.Handler { } // getDeploymentByDomain looks up a deployment by its domain +// Supports formats like: +// - {name}.node-{shortID}.{baseDomain} (e.g., myapp.node-kv4la8.dbrs.space) +// - {name}.{baseDomain} (e.g., myapp.dbrs.space for load-balanced/custom subdomain) +// - custom domains via deployment_domains table func (g *Gateway) getDeploymentByDomain(ctx context.Context, domain string) (*deployments.Deployment, error) { if g.deploymentService == nil { return nil, nil @@ -499,64 +504,138 @@ func (g *Gateway) getDeploymentByDomain(ctx context.Context, domain string) (*de // Strip trailing dot if present domain = strings.TrimSuffix(domain, ".") - // Get base domain from config (default to orama.network) - baseDomain := "orama.network" + // Get base domain from config (default to dbrs.space) + baseDomain := "dbrs.space" if g.cfg != nil && g.cfg.BaseDomain != "" { baseDomain = g.cfg.BaseDomain } - // Query deployment by domain (node-specific subdomain or custom domain) + // Query deployment by domain + // We need to match: + // 1. {name}.node-{shortID}.{baseDomain} - extract shortID and find deployment where + // 'node-' || substr(home_node_id, 9, 6) matches the node part + // 2. {subdomain}.{baseDomain} - match by subdomain field + // 3. Custom verified domain from deployment_domains table db := g.client.Database() internalCtx := client.WithInternalAuth(ctx) + // First, try to parse the domain to extract deployment name and node ID + // Format: {name}.node-{shortID}.{baseDomain} + suffix := "." + baseDomain + if strings.HasSuffix(domain, suffix) { + subdomain := strings.TrimSuffix(domain, suffix) + parts := strings.Split(subdomain, ".") + + // If we have 2 parts and second starts with "node-", it's a node-specific domain + if len(parts) == 2 && strings.HasPrefix(parts[1], "node-") { + deploymentName := parts[0] + shortNodeID := parts[1] // e.g., "node-kv4la8" + + // Query by name and matching short node ID + // Short ID is derived from peer ID: 'node-' + chars 9-14 of home_node_id + query := ` + SELECT id, namespace, name, type, port, content_cid, status, home_node_id + FROM deployments + WHERE name = ? + AND ('node-' || substr(home_node_id, 9, 6) = ? OR home_node_id = ?) + AND status = 'active' + LIMIT 1 + ` + result, err := db.Query(internalCtx, query, deploymentName, shortNodeID, shortNodeID) + if err == nil && len(result.Rows) > 0 { + row := result.Rows[0] + return &deployments.Deployment{ + ID: getString(row[0]), + Namespace: getString(row[1]), + Name: getString(row[2]), + Type: deployments.DeploymentType(getString(row[3])), + Port: getInt(row[4]), + ContentCID: getString(row[5]), + Status: deployments.DeploymentStatus(getString(row[6])), + HomeNodeID: getString(row[7]), + }, nil + } + } + + // Single subdomain: match by subdomain field (e.g., myapp.dbrs.space) + if len(parts) == 1 { + query := ` + SELECT id, namespace, name, type, port, content_cid, status, home_node_id + FROM deployments + WHERE subdomain = ? + AND status = 'active' + LIMIT 1 + ` + result, err := db.Query(internalCtx, query, parts[0]) + if err == nil && len(result.Rows) > 0 { + row := result.Rows[0] + return &deployments.Deployment{ + ID: getString(row[0]), + Namespace: getString(row[1]), + Name: getString(row[2]), + Type: deployments.DeploymentType(getString(row[3])), + Port: getInt(row[4]), + ContentCID: getString(row[5]), + Status: deployments.DeploymentStatus(getString(row[6])), + HomeNodeID: getString(row[7]), + }, nil + } + } + } + + // Try custom domain from deployment_domains table query := ` - SELECT d.id, d.namespace, d.name, d.type, d.port, d.content_cid, d.status + SELECT d.id, d.namespace, d.name, d.type, d.port, d.content_cid, d.status, d.home_node_id FROM deployments d - LEFT JOIN deployment_domains dd ON d.id = dd.deployment_id - WHERE (d.name || '.' || d.home_node_id || '.' || ? = ? - OR d.name || '.node-' || d.home_node_id || '.' || ? = ? - OR d.name || '.' || ? = ? - OR dd.domain = ? AND dd.verified_at IS NOT NULL) + JOIN deployment_domains dd ON d.id = dd.deployment_id + WHERE dd.domain = ? AND dd.verified_at IS NOT NULL AND d.status = 'active' LIMIT 1 ` - - result, err := db.Query(internalCtx, query, baseDomain, domain, baseDomain, domain, baseDomain, domain, domain) - if err != nil || result.Count == 0 { - return nil, err + result, err := db.Query(internalCtx, query, domain) + if err == nil && len(result.Rows) > 0 { + row := result.Rows[0] + return &deployments.Deployment{ + ID: getString(row[0]), + Namespace: getString(row[1]), + Name: getString(row[2]), + Type: deployments.DeploymentType(getString(row[3])), + Port: getInt(row[4]), + ContentCID: getString(row[5]), + Status: deployments.DeploymentStatus(getString(row[6])), + HomeNodeID: getString(row[7]), + }, nil } - if len(result.Rows) == 0 { - return nil, nil - } - - row := result.Rows[0] - if len(row) < 7 { - return nil, nil - } - - // Create deployment object - deployment := &deployments.Deployment{ - ID: getString(row[0]), - Namespace: getString(row[1]), - Name: getString(row[2]), - Type: deployments.DeploymentType(getString(row[3])), - Port: getInt(row[4]), - ContentCID: getString(row[5]), - Status: deployments.DeploymentStatus(getString(row[6])), - } - - return deployment, nil + return nil, nil } // proxyToDynamicDeployment proxies requests to a dynamic deployment's local port +// If the deployment is on a different node, it forwards the request to that node func (g *Gateway) proxyToDynamicDeployment(w http.ResponseWriter, r *http.Request, deployment *deployments.Deployment) { if deployment.Port == 0 { http.Error(w, "Deployment has no assigned port", http.StatusServiceUnavailable) return } - // Create a simple reverse proxy + // Check if request was already forwarded by another node (loop prevention) + proxyNode := r.Header.Get("X-Orama-Proxy-Node") + + // Check if this deployment is on the current node + if g.nodePeerID != "" && deployment.HomeNodeID != "" && + deployment.HomeNodeID != g.nodePeerID && proxyNode == "" { + // Need to proxy to home node + if g.proxyCrossNode(w, r, deployment) { + return // Request was proxied successfully + } + // Fall through if cross-node proxy failed - try local anyway + g.logger.Warn("Cross-node proxy failed, attempting local fallback", + zap.String("deployment", deployment.Name), + zap.String("home_node", deployment.HomeNodeID), + ) + } + + // Create a simple reverse proxy to localhost target := "http://localhost:" + strconv.Itoa(deployment.Port) // Set proxy headers @@ -584,8 +663,8 @@ func (g *Gateway) proxyToDynamicDeployment(w http.ResponseWriter, r *http.Reques } // Execute proxy request - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(proxyReq) + httpClient := &http.Client{Timeout: 30 * time.Second} + resp, err := httpClient.Do(proxyReq) if err != nil { g.logger.ComponentError(logging.ComponentGeneral, "proxy request failed", zap.String("target", target), @@ -610,6 +689,94 @@ func (g *Gateway) proxyToDynamicDeployment(w http.ResponseWriter, r *http.Reques } } +// proxyCrossNode forwards a request to the home node of a deployment +// Returns true if the request was successfully forwarded, false otherwise +func (g *Gateway) proxyCrossNode(w http.ResponseWriter, r *http.Request, deployment *deployments.Deployment) bool { + // Get home node IP from dns_nodes table + db := g.client.Database() + internalCtx := client.WithInternalAuth(r.Context()) + + query := "SELECT ip_address FROM dns_nodes WHERE id = ? LIMIT 1" + result, err := db.Query(internalCtx, query, deployment.HomeNodeID) + if err != nil || result == nil || len(result.Rows) == 0 { + g.logger.Warn("Failed to get home node IP", + zap.String("home_node_id", deployment.HomeNodeID), + zap.Error(err)) + return false + } + + homeIP := getString(result.Rows[0][0]) + if homeIP == "" { + g.logger.Warn("Home node IP is empty", zap.String("home_node_id", deployment.HomeNodeID)) + return false + } + + g.logger.Info("Proxying request to home node", + zap.String("deployment", deployment.Name), + zap.String("home_node_id", deployment.HomeNodeID), + zap.String("home_ip", homeIP), + zap.String("current_node", g.nodePeerID), + ) + + // Proxy to home node via HTTPS + // Use the original Host header so the home node's TLS works correctly + targetURL := "https://" + homeIP + r.URL.Path + if r.URL.RawQuery != "" { + targetURL += "?" + r.URL.RawQuery + } + + proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) + if err != nil { + g.logger.Error("Failed to create cross-node proxy request", zap.Error(err)) + return false + } + + // Copy headers and set Host header to original host + for key, values := range r.Header { + for _, value := range values { + proxyReq.Header.Add(key, value) + } + } + proxyReq.Host = r.Host // Keep original host for TLS SNI + proxyReq.Header.Set("X-Forwarded-For", getClientIP(r)) + proxyReq.Header.Set("X-Orama-Proxy-Node", g.nodePeerID) // Prevent loops + + // Skip TLS verification since we're connecting by IP with a Host header + // The home node has the correct certificate for the domain + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + ServerName: r.Host, // Use original host for SNI + }, + }, + } + + resp, err := httpClient.Do(proxyReq) + if err != nil { + g.logger.Error("Cross-node proxy request failed", + zap.String("target_ip", homeIP), + zap.String("host", r.Host), + zap.Error(err)) + return false + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + + // Write status code and body + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) + + return true +} + // Helper functions for type conversion func getString(v interface{}) string { if s, ok := v.(string); ok { diff --git a/pkg/gateway/routes.go b/pkg/gateway/routes.go index 222ee6d..938b4ec 100644 --- a/pkg/gateway/routes.go +++ b/pkg/gateway/routes.go @@ -15,6 +15,9 @@ func (g *Gateway) Routes() http.Handler { mux.HandleFunc("/v1/version", g.versionHandler) mux.HandleFunc("/v1/status", g.statusHandler) + // TLS check endpoint for Caddy on-demand TLS + mux.HandleFunc("/v1/internal/tls/check", g.tlsCheckHandler) + // auth endpoints mux.HandleFunc("/v1/auth/jwks", g.authService.JWKSHandler) mux.HandleFunc("/.well-known/jwks.json", g.authService.JWKSHandler) diff --git a/pkg/gateway/status_handlers.go b/pkg/gateway/status_handlers.go index f0666c3..d77779d 100644 --- a/pkg/gateway/status_handlers.go +++ b/pkg/gateway/status_handlers.go @@ -3,6 +3,7 @@ package gateway import ( "encoding/json" "net/http" + "strings" "time" "github.com/DeBrosOfficial/network/pkg/client" @@ -86,3 +87,29 @@ func (g *Gateway) versionHandler(w http.ResponseWriter, r *http.Request) { "uptime": time.Since(g.startedAt).String(), }) } + +// tlsCheckHandler validates if a domain should receive a TLS certificate +// Used by Caddy's on-demand TLS feature to prevent abuse +func (g *Gateway) tlsCheckHandler(w http.ResponseWriter, r *http.Request) { + domain := r.URL.Query().Get("domain") + if domain == "" { + http.Error(w, "domain parameter required", http.StatusBadRequest) + return + } + + // Get base domain from config + baseDomain := "dbrs.space" + if g.cfg != nil && g.cfg.BaseDomain != "" { + baseDomain = g.cfg.BaseDomain + } + + // Allow any subdomain of our base domain + if strings.HasSuffix(domain, "."+baseDomain) || domain == baseDomain { + w.WriteHeader(http.StatusOK) + return + } + + // Domain not allowed - only allow subdomains of our base domain + // Custom domains would need to be verified separately + http.Error(w, "domain not allowed", http.StatusForbidden) +} diff --git a/pkg/rqlite/migrations.go b/pkg/rqlite/migrations.go index 60efc9b..1506062 100644 --- a/pkg/rqlite/migrations.go +++ b/pkg/rqlite/migrations.go @@ -422,21 +422,93 @@ func splitSQLStatements(in string) []string { return out } -// Optional helper to load embedded migrations if you later decide to embed. -// Keep for future use; currently unused. -func readDirFS(fsys fs.FS, root string) ([]string, error) { - var files []string - err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - if strings.HasSuffix(strings.ToLower(d.Name()), ".sql") { - files = append(files, path) - } +// ApplyEmbeddedMigrations applies migrations from an embedded filesystem. +// This is the preferred method as it doesn't depend on filesystem paths. +func ApplyEmbeddedMigrations(ctx context.Context, db *sql.DB, fsys fs.FS, logger *zap.Logger) error { + if logger == nil { + logger = zap.NewNop() + } + + if err := ensureMigrationsTable(ctx, db); err != nil { + return fmt.Errorf("ensure schema_migrations: %w", err) + } + + files, err := readMigrationFilesFromFS(fsys) + if err != nil { + return fmt.Errorf("read embedded migration files: %w", err) + } + if len(files) == 0 { + logger.Info("No embedded migrations found") return nil - }) - return files, err + } + + applied, err := loadAppliedVersions(ctx, db) + if err != nil { + return fmt.Errorf("load applied versions: %w", err) + } + + for _, mf := range files { + if applied[mf.Version] { + logger.Debug("Migration already applied; skipping", zap.Int("version", mf.Version), zap.String("name", mf.Name)) + continue + } + + sqlBytes, err := fs.ReadFile(fsys, mf.Path) + if err != nil { + return fmt.Errorf("read embedded migration %s: %w", mf.Path, err) + } + + logger.Info("Applying migration", zap.Int("version", mf.Version), zap.String("name", mf.Name)) + if err := applySQL(ctx, db, string(sqlBytes)); err != nil { + return fmt.Errorf("apply migration %d (%s): %w", mf.Version, mf.Name, err) + } + + if _, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)`, mf.Version); err != nil { + return fmt.Errorf("record migration %d: %w", mf.Version, err) + } + logger.Info("Migration applied", zap.Int("version", mf.Version), zap.String("name", mf.Name)) + } + + return nil +} + +// ApplyEmbeddedMigrations is a convenience helper bound to RQLiteManager. +func (r *RQLiteManager) ApplyEmbeddedMigrations(ctx context.Context, fsys fs.FS) error { + db, err := sql.Open("rqlite", fmt.Sprintf("http://localhost:%d", r.config.RQLitePort)) + if err != nil { + return fmt.Errorf("open rqlite db: %w", err) + } + defer db.Close() + + return ApplyEmbeddedMigrations(ctx, db, fsys, r.logger) +} + +// readMigrationFilesFromFS reads migration files from an embedded filesystem. +func readMigrationFilesFromFS(fsys fs.FS) ([]migrationFile, error) { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil, err + } + + var out []migrationFile + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(strings.ToLower(name), ".sql") { + continue + } + ver, ok := parseVersionPrefix(name) + if !ok { + continue + } + out = append(out, migrationFile{ + Version: ver, + Name: name, + Path: name, // In embedded FS, path is just the filename + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version }) + return out, nil } diff --git a/pkg/rqlite/rqlite.go b/pkg/rqlite/rqlite.go index 087b6e2..87f7e75 100644 --- a/pkg/rqlite/rqlite.go +++ b/pkg/rqlite/rqlite.go @@ -7,6 +7,7 @@ import ( "syscall" "time" + "github.com/DeBrosOfficial/network/migrations" "github.com/DeBrosOfficial/network/pkg/config" "github.com/rqlite/gorqlite" "go.uber.org/zap" @@ -73,8 +74,14 @@ func (r *RQLiteManager) Start(ctx context.Context) error { return err } - migrationsDir, _ := r.resolveMigrationsDir() - _ = r.ApplyMigrations(ctx, migrationsDir) + // Apply embedded migrations - these are compiled into the binary + if err := r.ApplyEmbeddedMigrations(ctx, migrations.FS); err != nil { + r.logger.Error("Failed to apply embedded migrations", zap.Error(err)) + // Don't fail startup - migrations may have already been applied by another node + // or we may be joining an existing cluster + } else { + r.logger.Info("Database migrations applied successfully") + } return nil } diff --git a/testdata/apps/go-backend/app b/testdata/apps/go-backend/app new file mode 100755 index 0000000..16a5dde Binary files /dev/null and b/testdata/apps/go-backend/app differ diff --git a/testdata/apps/nodejs-backend/index.js b/testdata/apps/nodejs-backend/index.js new file mode 100644 index 0000000..1600d5b --- /dev/null +++ b/testdata/apps/nodejs-backend/index.js @@ -0,0 +1,37 @@ +const http = require('http'); + +const PORT = process.env.PORT || 3000; + +const server = http.createServer((req, res) => { + const url = req.url; + + if (url === '/health' || url === '/health/') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'healthy', + timestamp: new Date().toISOString(), + service: 'nodejs-backend-test' + })); + return; + } + + if (url === '/' || url === '/api') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + message: 'Hello from Node.js backend!', + timestamp: new Date().toISOString(), + environment: { + port: PORT, + nodeVersion: process.version + } + })); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); +}); + +server.listen(PORT, () => { + console.log(`Node.js backend listening on port ${PORT}`); +}); diff --git a/testdata/apps/nodejs-backend/package.json b/testdata/apps/nodejs-backend/package.json new file mode 100644 index 0000000..3cbe08d --- /dev/null +++ b/testdata/apps/nodejs-backend/package.json @@ -0,0 +1,9 @@ +{ + "name": "nodejs-backend-test", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": {} +}