fixed deployments

This commit is contained in:
anonpenguin23 2026-01-24 12:55:17 +02:00
parent 84c9b9ab9b
commit fc0b958b1e
21 changed files with 504 additions and 85 deletions

6
migrations/embed.go Normal file
View File

@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var FS embed.FS

View File

@ -19,7 +19,7 @@ type HTTPGatewayConfig struct {
IPFSClusterAPIURL string `yaml:"ipfs_cluster_api_url"` // IPFS Cluster API URL IPFSClusterAPIURL string `yaml:"ipfs_cluster_api_url"` // IPFS Cluster API URL
IPFSAPIURL string `yaml:"ipfs_api_url"` // IPFS API URL IPFSAPIURL string `yaml:"ipfs_api_url"` // IPFS API URL
IPFSTimeout time.Duration `yaml:"ipfs_timeout"` // Timeout for IPFS operations 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 // HTTPSConfig contains HTTPS/TLS configuration for the gateway

View File

@ -237,7 +237,8 @@ WantedBy=multi-user.target
func (m *Manager) getStartCommand(deployment *deployments.Deployment, workDir string) string { func (m *Manager) getStartCommand(deployment *deployments.Deployment, workDir string) string {
switch deployment.Type { switch deployment.Type {
case deployments.DeploymentTypeNextJS: 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: case deployments.DeploymentTypeNodeJSBackend:
// Check if ENTRY_POINT is set in environment // Check if ENTRY_POINT is set in environment
if entryPoint, ok := deployment.Environment["ENTRY_POINT"]; ok { if entryPoint, ok := deployment.Environment["ENTRY_POINT"]; ok {

View File

@ -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) // Create directory structure (unified structure)
if err := ps.fsProvisioner.EnsureDirectoryStructure(); err != nil { if err := ps.fsProvisioner.EnsureDirectoryStructure(); err != nil {
return fmt.Errorf("failed to create directory structure: %w", err) return fmt.Errorf("failed to create directory structure: %w", err)

View File

@ -182,6 +182,48 @@ func (up *UserProvisioner) SetupSudoersAccess(invokerUser string) error {
return nil 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 // StateDetector checks for existing production state
type StateDetector struct { type StateDetector struct {
oramaDir string oramaDir string

View File

@ -231,18 +231,13 @@ StandardOutput=append:%[4]s
StandardError=append:%[4]s StandardError=append:%[4]s
SyslogIdentifier=debros-node SyslogIdentifier=debros-node
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
PrivateTmp=yes PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only ProtectHome=read-only
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
ProtectControlGroups=yes ProtectControlGroups=yes
RestrictRealtime=yes RestrictRealtime=yes
RestrictSUIDSGID=yes ReadWritePaths=%[2]s /etc/systemd/system
ReadWritePaths=%[2]s
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -19,7 +19,7 @@ type Config struct {
TLSCacheDir string // Directory to cache TLS certificates (default: ~/.orama/tls-cache) TLSCacheDir string // Directory to cache TLS certificates (default: ~/.orama/tls-cache)
// Domain routing configuration // 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 // Data directory configuration
DataDir string // Base directory for node-local data (SQLite databases, deployments). Defaults to ~/.orama DataDir string // Base directory for node-local data (SQLite databases, deployments). Defaults to ~/.orama

View File

@ -106,8 +106,8 @@ func (h *GoHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
// Create DNS records // Create DNS records (use background context since HTTP context will be cancelled)
go h.service.CreateDNSRecords(ctx, deployment) go h.service.CreateDNSRecords(context.Background(), deployment)
// Build response // Build response
urls := h.service.BuildDeploymentURLs(deployment) urls := h.service.BuildDeploymentURLs(deployment)

View File

@ -65,8 +65,9 @@ func (h *ListHandler) HandleList(w http.ResponseWriter, r *http.Request) {
baseDomain := h.service.BaseDomain() baseDomain := h.service.BaseDomain()
deployments := make([]map[string]interface{}, len(rows)) deployments := make([]map[string]interface{}, len(rows))
for i, row := range rows { for i, row := range rows {
shortNodeID := GetShortNodeID(row.HomeNodeID)
urls := []string{ urls := []string{
"https://" + row.Name + "." + row.HomeNodeID + "." + baseDomain, "https://" + row.Name + "." + shortNodeID + "." + baseDomain,
} }
if row.Subdomain != "" { if row.Subdomain != "" {
urls = append(urls, "https://"+row.Subdomain+"."+baseDomain) urls = append(urls, "https://"+row.Subdomain+"."+baseDomain)

View File

@ -110,8 +110,8 @@ func (h *NextJSHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
// Create DNS records // Create DNS records (use background context since HTTP context will be cancelled)
go h.service.CreateDNSRecords(ctx, deployment) go h.service.CreateDNSRecords(context.Background(), deployment)
// Build response // Build response
urls := h.service.BuildDeploymentURLs(deployment) urls := h.service.BuildDeploymentURLs(deployment)
@ -186,6 +186,12 @@ func (h *NextJSHandler) deploySSR(ctx context.Context, namespace, name, subdomai
} }
deployment.Status = deployments.DeploymentStatusActive 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 return deployment, nil
} }

View File

@ -107,8 +107,8 @@ func (h *NodeJSHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
// Create DNS records // Create DNS records (use background context since HTTP context will be cancelled)
go h.service.CreateDNSRecords(ctx, deployment) go h.service.CreateDNSRecords(context.Background(), deployment)
// Build response // Build response
urls := h.service.BuildDeploymentURLs(deployment) urls := h.service.BuildDeploymentURLs(deployment)

View File

@ -33,7 +33,7 @@ func NewDeploymentService(
homeNodeManager: homeNodeManager, homeNodeManager: homeNodeManager,
portAllocator: portAllocator, portAllocator: portAllocator,
logger: logger, 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 // BaseDomain returns the configured base domain
func (s *DeploymentService) BaseDomain() string { func (s *DeploymentService) BaseDomain() string {
if s.baseDomain == "" { if s.baseDomain == "" {
return "orama.network" return "dbrs.space"
} }
return s.baseDomain 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 // CreateDeployment creates a new deployment
func (s *DeploymentService) CreateDeployment(ctx context.Context, deployment *deployments.Deployment) error { func (s *DeploymentService) CreateDeployment(ctx context.Context, deployment *deployments.Deployment) error {
// Assign home node if not already assigned // 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 // CreateDNSRecords creates DNS records for a deployment
func (s *DeploymentService) CreateDNSRecords(ctx context.Context, deployment *deployments.Deployment) error { 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) nodeIP, err := s.getNodeIP(ctx, deployment.HomeNodeID)
if err != nil { if err != nil {
s.logger.Error("Failed to get node IP", zap.Error(err)) s.logger.Error("Failed to get node IP", zap.Error(err))
return err return err
} }
// Create node-specific record // Use short node ID for the domain (e.g., node-kv4la8 instead of full peer ID)
nodeFQDN := fmt.Sprintf("%s.%s.%s.", deployment.Name, deployment.HomeNodeID, s.BaseDomain()) 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 { 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)) 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 != "" { if deployment.Subdomain != "" {
lbFQDN := fmt.Sprintf("%s.%s.", deployment.Subdomain, s.BaseDomain()) lbFQDN := fmt.Sprintf("%s.%s.", deployment.Subdomain, s.BaseDomain())
if err := s.createDNSRecord(ctx, lbFQDN, "A", nodeIP, deployment.Namespace, deployment.ID); err != nil { 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)) 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 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) { func (s *DeploymentService) getNodeIP(ctx context.Context, nodeID string) (string, error) {
type nodeRow struct { type nodeRow struct {
IPAddress string `db:"ip_address"` IPAddress string `db:"ip_address"`
} }
var rows []nodeRow var rows []nodeRow
// Try full node ID first
query := `SELECT ip_address FROM dns_nodes WHERE id = ? LIMIT 1` query := `SELECT ip_address FROM dns_nodes WHERE id = ? LIMIT 1`
err := s.db.Query(ctx, &rows, query, nodeID) err := s.db.Query(ctx, &rows, query, nodeID)
if err != nil { if err != nil {
return "", err return "", err
} }
if len(rows) == 0 { // If found, return it
return "", fmt.Errorf("node not found: %s", nodeID) if len(rows) > 0 {
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 rows[0].IPAddress, nil
} }
}
return "", fmt.Errorf("node not found: %s (tried: %s, %s)", nodeID, nodeID, shortID)
}
// BuildDeploymentURLs builds all URLs for a deployment // BuildDeploymentURLs builds all URLs for a deployment
func (s *DeploymentService) BuildDeploymentURLs(deployment *deployments.Deployment) []string { func (s *DeploymentService) BuildDeploymentURLs(deployment *deployments.Deployment) []string {
shortNodeID := GetShortNodeID(deployment.HomeNodeID)
urls := []string{ 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 != "" { if deployment.Subdomain != "" {

View File

@ -154,8 +154,8 @@ func (h *StaticDeploymentHandler) HandleUpload(w http.ResponseWriter, r *http.Re
return return
} }
// Create DNS records // Create DNS records (use background context since HTTP context will be cancelled)
go h.service.CreateDNSRecords(ctx, deployment) go h.service.CreateDNSRecords(context.Background(), deployment)
// Build URLs // Build URLs
urls := h.service.BuildDeploymentURLs(deployment) urls := h.service.BuildDeploymentURLs(deployment)

View File

@ -2,6 +2,7 @@ package gateway
import ( import (
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"io" "io"
"net" "net"
@ -198,7 +199,7 @@ func isPublicPath(p string) bool {
} }
switch p { 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 return true
default: default:
return false return false
@ -491,6 +492,10 @@ func (g *Gateway) domainRoutingMiddleware(next http.Handler) http.Handler {
} }
// getDeploymentByDomain looks up a deployment by its domain // 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) { func (g *Gateway) getDeploymentByDomain(ctx context.Context, domain string) (*deployments.Deployment, error) {
if g.deploymentService == nil { if g.deploymentService == nil {
return nil, nil return nil, nil
@ -499,44 +504,47 @@ func (g *Gateway) getDeploymentByDomain(ctx context.Context, domain string) (*de
// Strip trailing dot if present // Strip trailing dot if present
domain = strings.TrimSuffix(domain, ".") domain = strings.TrimSuffix(domain, ".")
// Get base domain from config (default to orama.network) // Get base domain from config (default to dbrs.space)
baseDomain := "orama.network" baseDomain := "dbrs.space"
if g.cfg != nil && g.cfg.BaseDomain != "" { if g.cfg != nil && g.cfg.BaseDomain != "" {
baseDomain = 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() db := g.client.Database()
internalCtx := client.WithInternalAuth(ctx) 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 := ` query := `
SELECT d.id, d.namespace, d.name, d.type, d.port, d.content_cid, d.status SELECT id, namespace, name, type, port, content_cid, status, home_node_id
FROM deployments d FROM deployments
LEFT JOIN deployment_domains dd ON d.id = dd.deployment_id WHERE name = ?
WHERE (d.name || '.' || d.home_node_id || '.' || ? = ? AND ('node-' || substr(home_node_id, 9, 6) = ? OR home_node_id = ?)
OR d.name || '.node-' || d.home_node_id || '.' || ? = ? AND status = 'active'
OR d.name || '.' || ? = ?
OR dd.domain = ? AND dd.verified_at IS NOT NULL)
AND d.status = 'active'
LIMIT 1 LIMIT 1
` `
result, err := db.Query(internalCtx, query, deploymentName, shortNodeID, shortNodeID)
result, err := db.Query(internalCtx, query, baseDomain, domain, baseDomain, domain, baseDomain, domain, domain) if err == nil && len(result.Rows) > 0 {
if err != nil || result.Count == 0 {
return nil, err
}
if len(result.Rows) == 0 {
return nil, nil
}
row := result.Rows[0] row := result.Rows[0]
if len(row) < 7 { return &deployments.Deployment{
return nil, nil
}
// Create deployment object
deployment := &deployments.Deployment{
ID: getString(row[0]), ID: getString(row[0]),
Namespace: getString(row[1]), Namespace: getString(row[1]),
Name: getString(row[2]), Name: getString(row[2]),
@ -544,19 +552,90 @@ func (g *Gateway) getDeploymentByDomain(ctx context.Context, domain string) (*de
Port: getInt(row[4]), Port: getInt(row[4]),
ContentCID: getString(row[5]), ContentCID: getString(row[5]),
Status: deployments.DeploymentStatus(getString(row[6])), Status: deployments.DeploymentStatus(getString(row[6])),
HomeNodeID: getString(row[7]),
}, nil
}
} }
return deployment, 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, d.home_node_id
FROM deployments d
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, 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
}
return nil, nil
} }
// proxyToDynamicDeployment proxies requests to a dynamic deployment's local port // 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) { func (g *Gateway) proxyToDynamicDeployment(w http.ResponseWriter, r *http.Request, deployment *deployments.Deployment) {
if deployment.Port == 0 { if deployment.Port == 0 {
http.Error(w, "Deployment has no assigned port", http.StatusServiceUnavailable) http.Error(w, "Deployment has no assigned port", http.StatusServiceUnavailable)
return 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) target := "http://localhost:" + strconv.Itoa(deployment.Port)
// Set proxy headers // Set proxy headers
@ -584,8 +663,8 @@ func (g *Gateway) proxyToDynamicDeployment(w http.ResponseWriter, r *http.Reques
} }
// Execute proxy request // Execute proxy request
client := &http.Client{Timeout: 30 * time.Second} httpClient := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(proxyReq) resp, err := httpClient.Do(proxyReq)
if err != nil { if err != nil {
g.logger.ComponentError(logging.ComponentGeneral, "proxy request failed", g.logger.ComponentError(logging.ComponentGeneral, "proxy request failed",
zap.String("target", target), 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 // Helper functions for type conversion
func getString(v interface{}) string { func getString(v interface{}) string {
if s, ok := v.(string); ok { if s, ok := v.(string); ok {

View File

@ -15,6 +15,9 @@ func (g *Gateway) Routes() http.Handler {
mux.HandleFunc("/v1/version", g.versionHandler) mux.HandleFunc("/v1/version", g.versionHandler)
mux.HandleFunc("/v1/status", g.statusHandler) mux.HandleFunc("/v1/status", g.statusHandler)
// TLS check endpoint for Caddy on-demand TLS
mux.HandleFunc("/v1/internal/tls/check", g.tlsCheckHandler)
// auth endpoints // auth endpoints
mux.HandleFunc("/v1/auth/jwks", g.authService.JWKSHandler) mux.HandleFunc("/v1/auth/jwks", g.authService.JWKSHandler)
mux.HandleFunc("/.well-known/jwks.json", g.authService.JWKSHandler) mux.HandleFunc("/.well-known/jwks.json", g.authService.JWKSHandler)

View File

@ -3,6 +3,7 @@ package gateway
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/DeBrosOfficial/network/pkg/client" "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(), "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)
}

View File

@ -422,21 +422,93 @@ func splitSQLStatements(in string) []string {
return out return out
} }
// Optional helper to load embedded migrations if you later decide to embed. // ApplyEmbeddedMigrations applies migrations from an embedded filesystem.
// Keep for future use; currently unused. // This is the preferred method as it doesn't depend on filesystem paths.
func readDirFS(fsys fs.FS, root string) ([]string, error) { func ApplyEmbeddedMigrations(ctx context.Context, db *sql.DB, fsys fs.FS, logger *zap.Logger) error {
var files []string if logger == nil {
err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error { 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 { if err != nil {
return err return fmt.Errorf("read embedded migration files: %w", err)
} }
if d.IsDir() { if len(files) == 0 {
logger.Info("No embedded migrations found")
return nil return nil
} }
if strings.HasSuffix(strings.ToLower(d.Name()), ".sql") {
files = append(files, path) 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 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
}) })
return files, err }
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
return out, nil
} }

View File

@ -7,6 +7,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/DeBrosOfficial/network/migrations"
"github.com/DeBrosOfficial/network/pkg/config" "github.com/DeBrosOfficial/network/pkg/config"
"github.com/rqlite/gorqlite" "github.com/rqlite/gorqlite"
"go.uber.org/zap" "go.uber.org/zap"
@ -73,8 +74,14 @@ func (r *RQLiteManager) Start(ctx context.Context) error {
return err return err
} }
migrationsDir, _ := r.resolveMigrationsDir() // Apply embedded migrations - these are compiled into the binary
_ = r.ApplyMigrations(ctx, migrationsDir) 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 return nil
} }

BIN
testdata/apps/go-backend/app vendored Executable file

Binary file not shown.

37
testdata/apps/nodejs-backend/index.js vendored Normal file
View File

@ -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}`);
});

View File

@ -0,0 +1,9 @@
{
"name": "nodejs-backend-test",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {}
}