mirror of
https://github.com/DeBrosOfficial/network.git
synced 2026-01-30 06:53:03 +00:00
fixed deployments
This commit is contained in:
parent
84c9b9ab9b
commit
fc0b958b1e
6
migrations/embed.go
Normal file
6
migrations/embed.go
Normal file
@ -0,0 +1,6 @@
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,44 +504,47 @@ 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 d.id, d.namespace, d.name, d.type, d.port, d.content_cid, d.status
|
||||
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)
|
||||
AND d.status = 'active'
|
||||
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, baseDomain, domain, baseDomain, domain, baseDomain, domain, domain)
|
||||
if err != nil || result.Count == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result.Rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, err := db.Query(internalCtx, query, deploymentName, shortNodeID, shortNodeID)
|
||||
if err == nil && len(result.Rows) > 0 {
|
||||
row := result.Rows[0]
|
||||
if len(row) < 7 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Create deployment object
|
||||
deployment := &deployments.Deployment{
|
||||
return &deployments.Deployment{
|
||||
ID: getString(row[0]),
|
||||
Namespace: getString(row[1]),
|
||||
Name: getString(row[2]),
|
||||
@ -544,19 +552,90 @@ func (g *Gateway) getDeploymentByDomain(ctx context.Context, domain string) (*de
|
||||
Port: getInt(row[4]),
|
||||
ContentCID: getString(row[5]),
|
||||
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
|
||||
// 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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
// 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 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
BIN
testdata/apps/go-backend/app
vendored
Executable file
BIN
testdata/apps/go-backend/app
vendored
Executable file
Binary file not shown.
37
testdata/apps/nodejs-backend/index.js
vendored
Normal file
37
testdata/apps/nodejs-backend/index.js
vendored
Normal 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}`);
|
||||
});
|
||||
9
testdata/apps/nodejs-backend/package.json
vendored
Normal file
9
testdata/apps/nodejs-backend/package.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "nodejs-backend-test",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user