Consolidate backend: drop vendored APwhy, converge on goose + sqlc

Phase 0 cleanup verified end-to-end:

- Delete vendored APwhy runtime (cmd/apwhy, internal/apwhy) and the
  unwired copy (internal/api/server.go + static bundle + cloned
  internal/{storage,analytics,auth,rbac}) — ~7.5k lines of dead code.
- Keep Containr's native internal/gateway; move APwhy management
  routes under /api/v1/gateway/* so they stop colliding with the
  native /services paths. Fix handlers against the real schema:
  UUID ids via RETURNING, integer enabled flags, plan validation,
  service validate endpoint, unique-violation mapping.
- Port agents.go off GORM onto sqlc (sqlc/queries/agents.sql);
  drop gorm dependencies; fix sqlc schema drift (services.environment_id).
- Replace the SELECT 1 goose baseline with the real consolidated
  schema (52 tables) minus APwhy's parallel auth/RBAC objects;
  idempotent via IF NOT EXISTS / OR REPLACE / guarded DO blocks so it
  replays cleanly over legacy installs. Add a drop migration for the
  dead objects and delete the legacy filename-tracked migrations/
  loader, directory, and Dockerfile copy. Verified via cmd/migrate:
  fresh install 53 tables, legacy upgrade clean to 20260917000000.
- Canvas: add invisible React Flow handles to ServiceNode so inferred
  auto-connections stop emitting warning #008.
- Remove reactflow dep (superseded by @xyflow/react), test harness
  cmds, and legacy 0001_init.sql backups.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 09:40:08 +02:00
1 parent 37a56dc6b6
commit 9e3aa3aa17
153 files changed
+6996 -11135

No files matched your search

-74
View File
@@ -1,74 +0,0 @@
package analytics
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
BaseURL string
APIKey string
WebsiteID string
HTTP *http.Client
}
func NewClient(baseURL, apiKey, websiteID string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
WebsiteID: websiteID,
HTTP: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) Enabled() bool {
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
}
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
if !c.Enabled() {
return map[string]any{
"enabled": false,
}, nil
}
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
if err != nil {
return nil, err
}
query := u.Query()
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Accept", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
}
var payload map[string]any
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, err
}
payload["enabled"] = true
return payload, nil
}
+400 -251
View File
@@ -1,7 +1,10 @@
package api
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"fmt"
"math"
"net/http"
@@ -9,26 +12,29 @@ import (
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/sqlc-dev/pqtype"
)
// NodeAgent represents a container orchestration agent
type NodeAgent struct {
ID string `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Hostname string `json:"hostname" gorm:"not null"`
IPAddress string `json:"ip_address" gorm:"not null"`
Port int `json:"port" gorm:"not null"`
Status string `json:"status" gorm:"default:'offline'"`
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
Port int `json:"port"`
Status string `json:"status"`
Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"`
Resources NodeResources `json:"resources" gorm:"serializer:json"`
Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata" gorm:"serializer:json"`
Metadata map[string]interface{} `json:"metadata"`
}
// AgentCapabilities defines what the agent can do
@@ -89,20 +95,20 @@ type BandwidthInfo struct {
// ContainerInstance represents a container running on an agent
type ContainerInstance struct {
ID string `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
Image string `json:"image" gorm:"not null"`
ProjectID string `json:"project_id" gorm:"not null"`
ServiceID string `json:"service_id" gorm:"not null"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
Status ContainerStatus `json:"status" gorm:"serializer:json"`
Resources ContainerResources `json:"resources" gorm:"serializer:json"`
Ports []PortMapping `json:"ports" gorm:"serializer:json"`
Environment map[string]string `json:"environment" gorm:"serializer:json"`
Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"`
Networks []string `json:"networks" gorm:"serializer:json"`
RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"`
HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"`
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
ProjectID string `json:"project_id"`
ServiceID string `json:"service_id"`
NodeAgentID string `json:"node_agent_id"`
Status ContainerStatus `json:"status"`
Resources ContainerResources `json:"resources"`
Ports []PortMapping `json:"ports"`
Environment map[string]string `json:"environment"`
Volumes []VolumeMount `json:"volumes"`
Networks []string `json:"networks"`
RestartPolicy RestartPolicy `json:"restart_policy"`
HealthCheck *HealthCheck `json:"health_check"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -155,12 +161,12 @@ type HealthCheck struct {
// AgentCommand represents a command sent to an agent
type AgentCommand struct {
ID string `json:"id" gorm:"primaryKey"`
Type string `json:"type" gorm:"not null"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"`
ID string `json:"id"`
Type string `json:"type"`
NodeAgentID string `json:"node_agent_id"`
ContainerID *string `json:"container_id"`
Payload map[string]interface{} `json:"payload" gorm:"serializer:json"`
Status string `json:"status" gorm:"default:'pending'"`
Payload map[string]interface{} `json:"payload"`
Status string `json:"status"`
Result *string `json:"result"`
Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"`
@@ -181,23 +187,6 @@ type AgentHeartbeat struct {
Version string `json:"version"`
}
type AgentHeartbeatRecord struct {
ID string `json:"id" gorm:"primaryKey"`
NodeAgentID string `json:"node_agent_id" gorm:"index;not null"`
Timestamp time.Time `json:"timestamp" gorm:"index;not null"`
Status string `json:"status"`
Resources NodeResources `json:"resources" gorm:"serializer:json"`
ContainerCount int `json:"container_count"`
SystemLoad SystemLoad `json:"system_load" gorm:"serializer:json"`
Uptime int64 `json:"uptime"`
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
func (AgentHeartbeatRecord) TableName() string {
return "agent_heartbeats"
}
type SystemLoad struct {
Load1M float64 `json:"load_1m"`
Load5M float64 `json:"load_5m"`
@@ -206,13 +195,125 @@ type SystemLoad struct {
// NodeAgentHandler handles agent-related endpoints
type NodeAgentHandler struct {
db *gorm.DB
q *sqlcdb.Queries
}
func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler {
return &NodeAgentHandler{db: db}
func NewNodeAgentHandler(db *database.DB) *NodeAgentHandler {
return &NodeAgentHandler{q: sqlcdb.New(db.DB)}
}
// --- row <-> API type conversion helpers ---
func rawJSON(v interface{}) pqtype.NullRawMessage {
if v == nil {
return pqtype.NullRawMessage{}
}
b, err := json.Marshal(v)
if err != nil {
return pqtype.NullRawMessage{}
}
return pqtype.NullRawMessage{RawMessage: b, Valid: true}
}
func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) {
if raw.Valid && len(raw.RawMessage) > 0 {
_ = json.Unmarshal(raw.RawMessage, dst)
}
}
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{
ID: row.ID,
Name: row.Name,
Hostname: row.Hostname,
IPAddress: row.IpAddress,
Port: int(row.Port),
Status: row.Status.String,
Version: row.Version.String,
Metadata: map[string]interface{}{},
}
if row.LastHeartbeat.Valid {
agent.LastHeartbeat = row.LastHeartbeat.Time
}
if row.CreatedAt.Valid {
agent.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
agent.UpdatedAt = row.UpdatedAt.Time
}
unmarshalRaw(row.Capabilities, &agent.Capabilities)
unmarshalRaw(row.Resources, &agent.Resources)
unmarshalRaw(row.Metadata, &agent.Metadata)
return agent
}
func containerFromRow(row sqlcdb.ContainerInstance) ContainerInstance {
container := ContainerInstance{
ID: row.ID,
Name: row.Name,
Image: row.Image,
ProjectID: row.ProjectID,
ServiceID: row.ServiceID,
NodeAgentID: row.NodeAgentID,
}
if row.CreatedAt.Valid {
container.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
container.UpdatedAt = row.UpdatedAt.Time
}
if row.StartedAt.Valid {
t := row.StartedAt.Time
container.StartedAt = &t
}
unmarshalRaw(row.Status, &container.Status)
unmarshalRaw(row.Resources, &container.Resources)
unmarshalRaw(row.Ports, &container.Ports)
unmarshalRaw(row.Environment, &container.Environment)
unmarshalRaw(row.Volumes, &container.Volumes)
unmarshalRaw(row.Networks, &container.Networks)
unmarshalRaw(row.RestartPolicy, &container.RestartPolicy)
if row.HealthCheck.Valid {
var hc HealthCheck
if err := json.Unmarshal(row.HealthCheck.RawMessage, &hc); err == nil {
container.HealthCheck = &hc
}
}
return container
}
func commandFromRow(row sqlcdb.AgentCommand) AgentCommand {
cmd := AgentCommand{
ID: row.ID,
Type: row.Type,
NodeAgentID: row.NodeAgentID,
Status: row.Status.String,
}
if row.ContainerID.Valid {
cmd.ContainerID = &row.ContainerID.String
}
if row.Result.Valid {
cmd.Result = &row.Result.String
}
if row.Error.Valid {
cmd.Error = &row.Error.String
}
if row.CreatedAt.Valid {
cmd.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
cmd.UpdatedAt = row.UpdatedAt.Time
}
if row.CompletedAt.Valid {
t := row.CompletedAt.Time
cmd.CompletedAt = &t
}
unmarshalRaw(row.Payload, &cmd.Payload)
return cmd
}
// --- handlers ---
// RegisterAgent handles agent registration
func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
var req struct {
@@ -235,85 +336,83 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
return
}
// Check if agent already exists
var existingAgent NodeAgent
if err := h.db.Where("hostname = ? AND ip_address = ?", req.Hostname, req.IPAddress).First(&existingAgent).Error; err == nil {
// Update existing agent
existingAgent.Name = req.Name
existingAgent.Port = req.Port
existingAgent.Capabilities = req.Capabilities
existingAgent.Status = "connecting"
existingAgent.LastHeartbeat = time.Now()
ctx := c.Request.Context()
if err := h.db.Save(&existingAgent).Error; err != nil {
// Re-registering an existing host updates it in place.
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
Hostname: req.Hostname,
IpAddress: req.IPAddress,
}); err == nil {
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
ID: existing.ID,
Name: req.Name,
Hostname: existing.Hostname,
IpAddress: existing.IpAddress,
Port: int32(req.Port),
Status: sql.NullString{String: "connecting", Valid: true},
Version: existing.Version,
Capabilities: rawJSON(req.Capabilities),
Resources: existing.Resources,
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: existing.Metadata,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.JSON(http.StatusOK, gin.H{
"agent_id": existingAgent.ID,
"agent_id": updated.ID,
"auth_token": authToken,
"status": "updated",
})
return
}
// Create new agent
agent := NodeAgent{
ID: uuid.New().String(),
Name: req.Name,
Hostname: req.Hostname,
IPAddress: req.IPAddress,
Port: req.Port,
Status: "connecting",
Capabilities: req.Capabilities,
Resources: NodeResources{
CPU: CPUResources{
Cores: 4,
Allocation: 0,
Usage: 0,
},
Memory: MemoryResources{
Total: 8 * 1024 * 1024 * 1024, // 8GB
Allocated: 0,
Used: 0,
Available: 8 * 1024 * 1024 * 1024,
},
Storage: StorageResources{
Total: 100 * 1024 * 1024 * 1024, // 100GB
Allocated: 0,
Used: 0,
Available: 100 * 1024 * 1024 * 1024,
},
Network: NetworkResources{
Interfaces: []NetworkInterface{
{
Name: "eth0",
IPAddress: req.IPAddress,
MACAddress: "00:00:00:00:00:00",
Speed: 1000,
Status: "up",
},
},
Bandwidth: BandwidthInfo{
Inbound: 0,
Outbound: 0,
agentID := uuid.New().String()
resources := NodeResources{
CPU: CPUResources{Cores: 4},
Memory: MemoryResources{
Total: 8 * 1024 * 1024 * 1024, // 8GB
Available: 8 * 1024 * 1024 * 1024,
},
Storage: StorageResources{
Total: 100 * 1024 * 1024 * 1024, // 100GB
Available: 100 * 1024 * 1024 * 1024,
},
Network: NetworkResources{
Interfaces: []NetworkInterface{
{
Name: "eth0",
IPAddress: req.IPAddress,
MACAddress: "00:00:00:00:00:00",
Speed: 1000,
Status: "up",
},
},
},
LastHeartbeat: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Metadata: make(map[string]interface{}),
}
if err := h.db.Create(&agent).Error; err != nil {
_, err := h.q.CreateAgent(ctx, sqlcdb.CreateAgentParams{
ID: agentID,
Name: req.Name,
Hostname: req.Hostname,
IpAddress: req.IPAddress,
Port: int32(req.Port),
Status: sql.NullString{String: "connecting", Valid: true},
Version: sql.NullString{},
Capabilities: rawJSON(req.Capabilities),
Resources: rawJSON(resources),
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: rawJSON(map[string]interface{}{}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
return
}
c.JSON(http.StatusCreated, gin.H{
"agent_id": agent.ID,
"agent_id": agentID,
"auth_token": authToken,
"status": "registered",
})
@@ -321,22 +420,25 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
// GetAgents returns all registered agents
func (h *NodeAgentHandler) GetAgents(c *gin.Context) {
var agents []NodeAgent
if err := h.db.Find(&agents).Error; err != nil {
rows, err := h.q.ListAgents(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"})
return
}
agents := make([]NodeAgent, 0, len(rows))
for _, row := range rows {
agents = append(agents, agentFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"agents": agents})
}
// GetAgent returns a specific agent
func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
id := c.Param("id")
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
row, err := h.q.GetAgent(c.Request.Context(), c.Param("id"))
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -344,16 +446,17 @@ func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"agent": agent})
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(row)})
}
// UpdateAgent updates an agent's information
func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
row, err := h.q.GetAgent(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -367,19 +470,53 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
return
}
if err := h.db.Model(&agent).Updates(updates).Error; err != nil {
agent := agentFromRow(row)
applyAgentUpdates(&agent, updates)
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
ID: agent.ID,
Name: agent.Name,
Hostname: agent.Hostname,
IpAddress: agent.IPAddress,
Port: int32(agent.Port),
Status: sql.NullString{String: agent.Status, Valid: agent.Status != ""},
Version: sql.NullString{String: agent.Version, Valid: agent.Version != ""},
Capabilities: rawJSON(agent.Capabilities),
Resources: rawJSON(agent.Resources),
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
Metadata: rawJSON(agent.Metadata),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
c.JSON(http.StatusOK, gin.H{"agent": agent})
c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)})
}
// applyAgentUpdates merges whitelisted keys from an arbitrary update map.
func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
remarshal := func(key string, dst interface{}) {
if v, ok := updates[key]; ok {
if b, err := json.Marshal(v); err == nil {
_ = json.Unmarshal(b, dst)
}
}
}
remarshal("name", &agent.Name)
remarshal("hostname", &agent.Hostname)
remarshal("ip_address", &agent.IPAddress)
remarshal("port", &agent.Port)
remarshal("status", &agent.Status)
remarshal("version", &agent.Version)
remarshal("capabilities", &agent.Capabilities)
remarshal("resources", &agent.Resources)
remarshal("metadata", &agent.Metadata)
}
// DeleteAgent removes an agent
func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) {
id := c.Param("id")
if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil {
if err := h.q.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"})
return
}
@@ -403,9 +540,9 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
heartbeat.Timestamp = time.Now()
}
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ctx := c.Request.Context()
if _, err := h.q.GetAgent(ctx, heartbeat.NodeAgentID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -413,35 +550,31 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
return
}
// Update agent status and resources
agent.Status = heartbeat.Status
agent.Resources = heartbeat.Resources
agent.LastHeartbeat = heartbeat.Timestamp
agent.UpdatedAt = time.Now()
if err := h.db.Save(&agent).Error; err != nil {
if err := h.q.UpdateAgentHeartbeat(ctx, sqlcdb.UpdateAgentHeartbeatParams{
ID: heartbeat.NodeAgentID,
Status: sql.NullString{String: heartbeat.Status, Valid: heartbeat.Status != ""},
Resources: rawJSON(heartbeat.Resources),
LastHeartbeat: sql.NullTime{Time: heartbeat.Timestamp, Valid: true},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return
}
record := AgentHeartbeatRecord{
err := h.q.InsertAgentHeartbeat(ctx, sqlcdb.InsertAgentHeartbeatParams{
ID: uuid.New().String(),
NodeAgentID: heartbeat.NodeAgentID,
Timestamp: heartbeat.Timestamp,
Status: heartbeat.Status,
Resources: heartbeat.Resources,
ContainerCount: heartbeat.ContainerCount,
SystemLoad: heartbeat.SystemLoad,
Resources: rawJSON(heartbeat.Resources).RawMessage,
ContainerCount: int32(heartbeat.ContainerCount),
SystemLoad: rawJSON(heartbeat.SystemLoad).RawMessage,
Uptime: heartbeat.Uptime,
Version: heartbeat.Version,
CreatedAt: time.Now(),
}
if err := h.db.Create(&record).Error; err != nil {
})
if err != nil && !isMissingTableError(err) {
// Keep heartbeat endpoint available even if history table is not yet migrated.
if !isMissingTableError(err) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
return
}
c.Status(http.StatusOK)
@@ -454,17 +587,17 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
return
}
agentID := c.Param("id")
var commands []AgentCommand
if err := h.db.
Where("node_agent_id = ? AND status = ?", agentID, "pending").
Order("created_at ASC").
Limit(25).
Find(&commands).Error; err != nil {
rows, err := h.q.ListPendingCommands(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
return
}
commands := make([]AgentCommand, 0, len(rows))
for _, row := range rows {
commands = append(commands, commandFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"commands": commands})
}
@@ -491,45 +624,39 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
return
}
var command AgentCommand
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ctx := c.Request.Context()
command, err := h.q.CompleteCommand(ctx, sqlcdb.CompleteCommandParams{
ID: commandID,
NodeAgentID: agentID,
Status: sql.NullString{String: req.Status, Valid: true},
Result: sql.NullString{String: req.Result, Valid: req.Result != ""},
Error: sql.NullString{String: req.Error, Valid: req.Error != ""},
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
return
}
now := time.Now()
command.Status = req.Status
command.UpdatedAt = now
command.CompletedAt = &now
if req.Result != "" {
command.Result = &req.Result
}
if req.Error != "" {
command.Error = &req.Error
}
if err := h.db.Save(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"})
return
}
if command.ContainerID != nil {
h.updateContainerStatusAfterCommand(command)
out := commandFromRow(command)
if out.ContainerID != nil {
h.updateContainerStatusAfterCommand(ctx, out)
}
c.JSON(http.StatusOK, gin.H{"command": command})
c.JSON(http.StatusOK, gin.H{"command": out})
}
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) {
var container ContainerInstance
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil {
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
row, err := h.q.GetContainer(ctx, *command.ContainerID)
if err != nil {
return
}
container := containerFromRow(row)
if command.Status == "failed" {
errorMessage := ""
if command.Error != nil {
@@ -537,7 +664,10 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
}
container.Status.State = "error"
container.Status.Error = &errorMessage
_ = h.db.Save(&container).Error
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: container.ID,
Status: rawJSON(container.Status),
})
return
}
@@ -555,20 +685,25 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
container.Status.State = "removed"
container.Status.FinishedAt = &now
}
container.UpdatedAt = now
_ = h.db.Save(&container).Error
_ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: container.ID,
Status: rawJSON(container.Status),
})
}
// GetAgentContainers returns containers running on a specific agent
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
agentID := c.Param("id")
var containers []ContainerInstance
if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil {
rows, err := h.q.ListContainersForAgent(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"})
return
}
containers := make([]ContainerInstance, 0, len(rows))
for _, row := range rows {
containers = append(containers, containerFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"containers": containers})
}
@@ -595,10 +730,11 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
return
}
ctx := c.Request.Context()
// Verify agent exists
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
if _, err := h.q.GetAgent(ctx, agentID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
@@ -606,17 +742,15 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
return
}
containerID := uuid.New().String()
container := ContainerInstance{
ID: uuid.New().String(),
Name: req.Name,
Image: req.Image,
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
NodeAgentID: agentID,
Status: ContainerStatus{
State: "created",
Health: "none",
},
ID: containerID,
Name: req.Name,
Image: req.Image,
ProjectID: req.ProjectID,
ServiceID: req.ServiceID,
NodeAgentID: agentID,
Status: ContainerStatus{State: "created", Health: "none"},
Resources: req.Resources,
Ports: req.Ports,
Environment: req.Environment,
@@ -628,26 +762,36 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
UpdatedAt: time.Now(),
}
if err := h.db.Create(&container).Error; err != nil {
_, err := h.q.CreateContainer(ctx, sqlcdb.CreateContainerParams{
ID: container.ID,
Name: container.Name,
Image: container.Image,
ProjectID: container.ProjectID,
ServiceID: container.ServiceID,
NodeAgentID: container.NodeAgentID,
Status: rawJSON(container.Status),
Resources: rawJSON(container.Resources),
Ports: rawJSON(container.Ports),
Environment: rawJSON(container.Environment),
Volumes: rawJSON(container.Volumes),
Networks: rawJSON(container.Networks),
RestartPolicy: rawJSON(container.RestartPolicy),
HealthCheck: rawJSON(container.HealthCheck),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"})
return
}
// Create command to start container on agent
command := AgentCommand{
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: "create_container",
NodeAgentID: agentID,
ContainerID: &container.ID,
Payload: map[string]interface{}{
"container": container,
},
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
ContainerID: sql.NullString{String: container.ID, Valid: true},
Payload: rawJSON(map[string]interface{}{"container": container}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"})
return
}
@@ -669,45 +813,44 @@ func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
return
}
command := AgentCommand{
command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: req.Type,
NodeAgentID: agentID,
Payload: req.Payload,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
Payload: rawJSON(req.Payload),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
c.JSON(http.StatusCreated, gin.H{"command": command})
c.JSON(http.StatusCreated, gin.H{"command": commandFromRow(command)})
}
// GetAgentCommands returns commands for an agent
func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) {
agentID := c.Param("id")
var commands []AgentCommand
if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").Find(&commands).Error; err != nil {
rows, err := h.q.ListCommandsForAgent(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
return
}
commands := make([]AgentCommand, 0, len(rows))
for _, row := range rows {
commands = append(commands, commandFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"commands": commands})
}
// GetCommandStatus returns the status of a specific command
func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
agentID := c.Param("id")
commandID := c.Param("commandId")
var command AgentCommand
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
command, err := h.q.GetCommandForAgent(c.Request.Context(), sqlcdb.GetCommandForAgentParams{
ID: c.Param("commandId"),
NodeAgentID: c.Param("id"),
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
return
}
@@ -715,7 +858,7 @@ func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"command": command})
c.JSON(http.StatusOK, gin.H{"command": commandFromRow(command)})
}
// ContainerAction handles container lifecycle actions
@@ -739,10 +882,15 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
return
}
ctx := c.Request.Context()
// Verify container exists
var container ContainerInstance
if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
containerRow, err := h.q.GetContainerForAgent(ctx, sqlcdb.GetContainerForAgentParams{
ID: containerID,
NodeAgentID: agentID,
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return
}
@@ -751,22 +899,18 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
}
// Create command for the action
command := AgentCommand{
_, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
ID: uuid.New().String(),
Type: fmt.Sprintf("%s_container", action),
NodeAgentID: agentID,
ContainerID: &container.ID,
Payload: map[string]interface{}{
ContainerID: sql.NullString{String: containerRow.ID, Valid: true},
Payload: rawJSON(map[string]interface{}{
"container_id": containerID,
"container_name": container.Name,
"docker_name": container.Name,
},
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
"container_name": containerRow.Name,
"docker_name": containerRow.Name,
}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return
}
@@ -794,22 +938,23 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
return
}
var agent NodeAgent
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ctx := c.Request.Context()
agentRow, err := h.q.GetAgent(ctx, agentID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return
}
agent := agentFromRow(agentRow)
from := time.Now().Add(-duration)
var records []AgentHeartbeatRecord
queryErr := h.db.
Where("node_agent_id = ? AND timestamp >= ?", agentID, from).
Order("timestamp ASC").
Find(&records).Error
records, queryErr := h.q.ListAgentHeartbeatsSince(ctx, sqlcdb.ListAgentHeartbeatsSinceParams{
NodeAgentID: agentID,
Timestamp: from,
})
if queryErr != nil && !isMissingTableError(queryErr) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"})
return
@@ -817,7 +962,11 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
metrics := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
metrics = append(metrics, buildMetricPoint(record.Timestamp, record.Resources, record.SystemLoad, record.ContainerCount))
var resources NodeResources
var load SystemLoad
_ = json.Unmarshal(record.Resources, &resources)
_ = json.Unmarshal(record.SystemLoad, &load)
metrics = append(metrics, buildMetricPoint(record.Timestamp, resources, load, int(record.ContainerCount)))
}
if len(metrics) == 0 {
+128 -72
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
@@ -13,21 +14,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
// Constants for validation and limits
// Default quota limits applied when the request omits them.
const (
MaxServiceNameLength = 100
MaxRoutePrefixLength = 200
MaxUpstreamURLLength = 500
MaxAPIKeyNameLength = 100
MinAPIKeyLength = 20
MaxAPIKeyLength = 100
DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000
MaxRPMLimit = 10000
MaxMonthlyQuota = 10000000
DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000
)
// Validator instance for request validation
@@ -75,15 +69,6 @@ type APIKeyRequest struct {
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
}
// generateServiceID generates a cryptographically secure service ID
func generateServiceID() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return "svc_" + hex.EncodeToString(bytes), nil
}
// generateAPIKey generates a cryptographically secure API key
func generateAPIKey() (string, error) {
bytes := make([]byte, 32)
@@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error {
return nil
}
// isUniqueViolation reports whether err is a Postgres unique-constraint violation.
func isUniqueViolation(err error) bool {
var pqErr *pq.Error
return errors.As(err, &pqErr) && pqErr.Code == "23505"
}
// handleAPwhyServiceValidate probes the upstream health path and records the
// result on the service row. Ported from the standalone APwhy server.
func handleAPwhyServiceValidate(c *gin.Context) {
serviceID := c.Param("id")
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
var upstreamURL, healthPath string
var authHeader, authValue sql.NullString
var timeoutMs sql.NullInt64
err := db.QueryRowContext(ctx, `
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
if err != nil {
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
return
}
timeout := 8000
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
timeout = int(timeoutMs.Int64)
}
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if authHeader.Valid && authHeader.String != "" {
req.Header.Set(authHeader.String, authValue.String)
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond}
res, probeErr := client.Do(req)
status := http.StatusBadGateway
message := ""
ok := false
if probeErr != nil {
message = probeErr.Error()
} else {
defer res.Body.Close()
status = res.StatusCode
message = fmt.Sprintf("HTTP %d", res.StatusCode)
ok = res.StatusCode >= 200 && res.StatusCode < 300
}
validationStatus := "failed"
if ok {
validationStatus = "healthy"
}
_, _ = db.ExecContext(ctx, `
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`, validationStatus, message, serviceID)
if !ok {
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`, serviceID, message, status)
}
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
"validation": map[string]interface{}{
"ok": ok,
"status": status,
"message": message,
},
})
}
// handleAPwhyServicesList returns a list of API services
func handleAPwhyServicesList(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
@@ -170,7 +233,7 @@ func handleAPwhyServicesList(c *gin.Context) {
// Query services from database
rows, err := db.QueryContext(ctx, `
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
FROM api_services
FROM api_services
ORDER BY created_at DESC
`)
if err != nil {
@@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) {
}
defer rows.Close()
var services []map[string]interface{}
services := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
var enabled bool
var enabled int
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
if err != nil {
continue // Skip malformed rows
@@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) {
"slug": slug,
"upstreamUrl": upstreamURL,
"routePrefix": routePrefix,
"enabled": enabled,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
})
@@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
ctx := context.Background()
// Generate slug and ID
id, err := generateServiceID()
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
"Failed to generate service ID", err.Error())
return
}
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
// Insert service into database
query := `
INSERT INTO api_services (
id, name, slug, upstream_url, route_prefix, health_path,
enabled, rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW())
`
var rpmLimit, monthlyQuota int
if req.RPMLimit != nil {
rpmLimit = *req.RPMLimit
@@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) {
monthlyQuota = DefaultMonthlyQuota
}
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL,
req.RoutePrefix, rpmLimit, monthlyQuota)
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err := db.QueryRowContext(ctx, `
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
sendErrorResponse(c, http.StatusConflict, "CONFLICT",
"A service with this slug or route prefix already exists", err.Error())
return
}
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to create service", err.Error())
return
@@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, serviceID)
enabled, serviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) {
}
defer rows.Close()
var keys []map[string]interface{}
keys := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled bool
var enabled int
var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
@@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) {
"name": name,
"keyPrefix": keyPrefix,
"plan": plan,
"enabled": enabled,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
}
@@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) {
return
}
// Generate ID and prefix
id, err := generateServiceID()
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
"Failed to generate key ID", err.Error())
return
}
keyPrefix := apiKey[:8]
// Set default limits based on plan
var rpmLimit, monthlyQuota int
switch input.Plan {
case "free":
rpmLimit, monthlyQuota = 60, 1000
case "pro":
rpmLimit, monthlyQuota = 600, 50000
case "business":
rpmLimit, monthlyQuota = 3000, 300000
default:
rpmLimit, monthlyQuota = 60, 1000
plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan)
if err != nil {
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid plan", err.Error())
return
}
// Insert key into database
_, err = db.ExecContext(context.Background(), `
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err = db.QueryRowContext(context.Background(), `
INSERT INTO api_keys (
id, name, key_hash, key_prefix, plan, allowed_service_ids,
enabled, rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW())
`, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota)
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
"data": gin.H{
"id": id,
"name": input.Name,
"plan": input.Plan,
"plan": plan,
"key": apiKey, // Only return the actual key once
"keyPrefix": keyPrefix,
"enabled": true,
@@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, keyID)
enabled, keyID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
+15 -1
View File
@@ -1,6 +1,10 @@
package api
import "github.com/gin-gonic/gin"
import (
"strings"
"github.com/gin-gonic/gin"
)
// firstPathParam returns the first non-empty route param from the provided names.
func firstPathParam(c *gin.Context, names ...string) string {
@@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string {
}
return ""
}
// firstNonEmpty returns the first non-blank string, trimmed.
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
+16 -44
View File
@@ -17,8 +17,6 @@ import (
"containr/internal/scaling"
"github.com/gin-gonic/gin"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
@@ -56,14 +54,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Initialize scaling handler
scalingHandler := NewScalingHandler(autoScaler)
// Initialize GORM for agent system
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
if err != nil {
panic("Failed to initialize GORM: " + err.Error())
}
// Initialize agent handler
agentHandler := NewNodeAgentHandler(gormDB)
// Initialize agent handler (sqlc-backed)
agentHandler := NewNodeAgentHandler(db)
// Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
@@ -90,7 +82,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Set("auto_scaler", autoScaler)
c.Set("ha_manager", haManager)
c.Set("scaling_handler", scalingHandler)
c.Set("gorm_db", gormDB)
c.Next()
})
@@ -298,42 +289,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Audit Logs routes
protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
}
// APwhy Gateway routes
apwhy := router.Group("/api/v1")
{
// Health check (no auth required)
apwhy.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"ok": true,
"data": gin.H{
"status": "ok",
"name": "Containr + APwhy",
"database": "postgresql",
"generatedAt": time.Now().UTC().Format(time.RFC3339),
},
})
})
}
// API Gateway routes (merged APwhy) - namespaced to avoid
// colliding with Containr's own /services paths.
gateway := protected.Group("/gateway")
{
gateway.GET("/services", handleAPwhyServicesList)
gateway.POST("/services", handleAPwhyServicesCreate)
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
// Protected APwhy routes (authentication required)
protectedAPwhy := router.Group("/api/v1")
protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret))
{
// Service management
protectedAPwhy.GET("/services", handleAPwhyServicesList)
protectedAPwhy.POST("/services", handleAPwhyServicesCreate)
protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/keys", handleAPwhyKeysList)
gateway.POST("/keys", handleAPwhyKeysCreate)
gateway.PATCH("/keys/:id", handleAPwhyKeysPatch)
// API Keys
protectedAPwhy.GET("/keys", handleAPwhyKeysList)
protectedAPwhy.POST("/keys", handleAPwhyKeysCreate)
protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch)
// Analytics
protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps)
protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps)
gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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 truncated
@@ -1 +0,0 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s<e.observers.length;s+=1){const i=e.observers[s],o=xe&&xe.running;o&&xe.disposed.has(i),(o?!i.tState:!i.state)&&(i.pure?I.push(i):q.push(i),i.observers&&Ke(i)),o||(i.state=J)}if(I.length>1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspenLine truncated
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>APwhy</title>
<script type="module" crossorigin src="/assets/index-DwfYiTMH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DRUelTBf.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+2 -2
View File
@@ -310,8 +310,8 @@ func handleCreateFromTemplate(c *gin.Context) {
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Type: serviceType,
Status: "stopped",
Type: sql.NullString{String: serviceType, Valid: true},
Status: sql.NullString{String: "stopped", Valid: true},
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
@@ -1,74 +0,0 @@
package analytics
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
BaseURL string
APIKey string
WebsiteID string
HTTP *http.Client
}
func NewClient(baseURL, apiKey, websiteID string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
WebsiteID: websiteID,
HTTP: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) Enabled() bool {
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
}
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
if !c.Enabled() {
return map[string]any{
"enabled": false,
}, nil
}
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
if err != nil {
return nil, err
}
query := u.Query()
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Accept", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
}
var payload map[string]any
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, err
}
payload["enabled"] = true
return payload, nil
}
File diff suppressed because it is too large. Load diff
@@ -1,321 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strings"
"testing"
"containr/internal/apwhy/config"
"containr/internal/apwhy/storage"
)
type apiErrorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
}
type apiResponsePayload struct {
OK bool `json:"ok"`
Data any `json:"data"`
Error apiErrorPayload `json:"error"`
}
func newTestServer(t *testing.T) (*httptest.Server, *http.Client) {
t.Helper()
cfg := config.Load()
cfg.SQLitePath = ":memory:"
cfg.CookieSecure = false
store, err := storage.Open(cfg)
if err != nil {
t.Fatalf("failed to open test storage: %v", err)
}
server := NewServer(store, cfg)
ts := httptest.NewServer(server.Handler())
t.Cleanup(func() {
ts.Close()
_ = store.Close()
})
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("failed to create cookie jar: %v", err)
}
return ts, &http.Client{Jar: jar}
}
func requestJSON(t *testing.T, client *http.Client, method string, url string, body string) (int, apiResponsePayload) {
t.Helper()
var bodyReader *bytes.Reader
if body == "" {
bodyReader = bytes.NewReader(nil)
} else {
bodyReader = bytes.NewReader([]byte(body))
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
var payload apiResponsePayload
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode response body: %v", err)
}
return resp.StatusCode, payload
}
func bootstrapAndLoginOwner(t *testing.T, client *http.Client, baseURL string) {
t.Helper()
status, payload := requestJSON(
t,
client,
http.MethodPost,
baseURL+"/api/v1/bootstrap/register-owner",
`{"email":"[email protected]","password":"owner-pass-123"}`,
)
if status != http.StatusCreated || !payload.OK {
t.Fatalf("owner bootstrap failed: status=%d payload=%+v", status, payload)
}
status, payload = requestJSON(
t,
client,
http.MethodPost,
baseURL+"/api/v1/auth/login",
`{"email":"[email protected]","password":"owner-pass-123"}`,
)
if status != http.StatusOK || !payload.OK {
t.Fatalf("owner login failed: status=%d payload=%+v", status, payload)
}
}
func TestServerBootstrapLoginAndProtectedRoutes(t *testing.T) {
ts, authClient := newTestServer(t)
baseURL := ts.URL
// Bootstrap should be open before any user exists.
status, payload := requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("unexpected bootstrap status response: status=%d payload=%+v", status, payload)
}
bootstrapData, ok := payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
}
if hasUsers, _ := bootstrapData["hasUsers"].(bool); hasUsers {
t.Fatalf("expected hasUsers=false before owner bootstrap")
}
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); !registrationOpen {
t.Fatalf("expected registrationOpen=true before owner bootstrap")
}
// Protected route without auth should fail.
anonClient := &http.Client{}
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
if status != http.StatusUnauthorized || payload.OK {
t.Fatalf("expected unauthorized /auth/me response, got status=%d payload=%+v", status, payload)
}
// Register owner and login.
bootstrapAndLoginOwner(t, authClient, baseURL)
// Bootstrap should close once owner exists.
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("unexpected bootstrap status after owner creation: status=%d payload=%+v", status, payload)
}
bootstrapData, ok = payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
}
if hasUsers, _ := bootstrapData["hasUsers"].(bool); !hasUsers {
t.Fatalf("expected hasUsers=true after owner bootstrap")
}
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); registrationOpen {
t.Fatalf("expected registrationOpen=false after owner bootstrap")
}
// Owner session cookie should authorize /auth/me.
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("expected successful /auth/me response, got status=%d payload=%+v", status, payload)
}
meData, ok := payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for auth/me, got %#v", payload.Data)
}
if email, _ := meData["email"].(string); email != "[email protected]" {
t.Fatalf("unexpected /auth/me email: %q", email)
}
// Logout should revoke auth session.
status, payload = requestJSON(t, authClient, http.MethodPost, baseURL+"/api/v1/auth/logout", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("expected successful logout response, got status=%d payload=%+v", status, payload)
}
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
if status != http.StatusUnauthorized || payload.OK {
t.Fatalf("expected unauthorized /auth/me after logout, got status=%d payload=%+v", status, payload)
}
}
func TestServerServicesCreateValidationRules(t *testing.T) {
ts, client := newTestServer(t)
baseURL := ts.URL
bootstrapAndLoginOwner(t, client, baseURL)
// Route prefix under /api must be rejected.
status, payload := requestJSON(
t,
client,
http.MethodPost,
baseURL+"/api/v1/services",
`{"name":"Bad Service","upstreamUrl":"http://example.com","routePrefix":"/api/conflict"}`,
)
if status != http.StatusBadRequest || payload.OK {
t.Fatalf("expected route conflict validation failure, got status=%d payload=%+v", status, payload)
}
if payload.Error.Code != "ROUTE_CONFLICT" {
t.Fatalf("expected ROUTE_CONFLICT error code, got %q", payload.Error.Code)
}
// Valid service should be created successfully.
status, payload = requestJSON(
t,
client,
http.MethodPost,
baseURL+"/api/v1/services",
`{"name":"Web Frontend","upstreamUrl":"http://example.com","routePrefix":"/web"}`,
)
if status != http.StatusCreated || !payload.OK {
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
}
createData, ok := payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for service creation, got %#v", payload.Data)
}
if _, ok := createData["id"].(string); !ok {
t.Fatalf("expected created service id in response")
}
if slug, _ := createData["slug"].(string); !strings.Contains(slug, "web-frontend") {
t.Fatalf("expected normalized service slug, got %q", slug)
}
// List should contain the newly created service.
status, payload = requestJSON(t, client, http.MethodGet, baseURL+"/api/v1/services", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("expected successful services list response, got status=%d payload=%+v", status, payload)
}
rows, ok := payload.Data.([]any)
if !ok || len(rows) == 0 {
t.Fatalf("expected non-empty services list, got %#v", payload.Data)
}
}
func TestServerProxyWithAPIKey(t *testing.T) {
ts, authClient := newTestServer(t)
baseURL := ts.URL
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-api-key") != "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, "unexpected-api-key-header")
return
}
_, _ = io.WriteString(w, "ok:"+r.URL.Path+"?"+r.URL.RawQuery)
}))
t.Cleanup(upstream.Close)
bootstrapAndLoginOwner(t, authClient, baseURL)
// Create service exposed at /web/* and proxied to the upstream test server.
status, payload := requestJSON(
t,
authClient,
http.MethodPost,
baseURL+"/api/v1/services",
`{"name":"Web Proxy","upstreamUrl":"`+upstream.URL+`","routePrefix":"/web"}`,
)
if status != http.StatusCreated || !payload.OK {
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
}
// Mint API key for gateway access.
status, payload = requestJSON(
t,
authClient,
http.MethodPost,
baseURL+"/api/v1/keys",
`{"name":"integration-key","plan":"free"}`,
)
if status != http.StatusCreated || !payload.OK {
t.Fatalf("expected successful key creation, got status=%d payload=%+v", status, payload)
}
keyData, ok := payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for key creation, got %#v", payload.Data)
}
rawKey, _ := keyData["key"].(string)
if strings.TrimSpace(rawKey) == "" {
t.Fatalf("expected non-empty raw API key")
}
// No API key should be rejected.
anonClient := &http.Client{}
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/web/ping?x=1", "")
if status != http.StatusUnauthorized || payload.OK {
t.Fatalf("expected unauthorized proxy request without key, got status=%d payload=%+v", status, payload)
}
if payload.Error.Code != "API_KEY_MISSING" {
t.Fatalf("expected API_KEY_MISSING error code, got %q", payload.Error.Code)
}
// Valid API key should proxy request and strip the key before forwarding.
req, err := http.NewRequest(http.MethodGet, baseURL+"/web/ping?x=1", nil)
if err != nil {
t.Fatalf("failed to create proxy request: %v", err)
}
req.Header.Set("x-api-key", rawKey)
resp, err := anonClient.Do(req)
if err != nil {
t.Fatalf("proxy request failed: %v", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read proxy response body: %v", err)
}
body := string(bodyBytes)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected proxy status 200, got %d body=%q", resp.StatusCode, body)
}
if body != "ok:/ping?x=1" {
t.Fatalf("unexpected proxy body: %q", body)
}
}
@@ -1 +0,0 @@
@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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 truncated
@@ -1 +0,0 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s<e.observers.length;s+=1){const i=e.observers[s],o=xe&&xe.running;o&&xe.disposed.has(i),(o?!i.tState:!i.state)&&(i.pure?I.push(i):q.push(i),i.observers&&Ke(i)),o||(i.state=J)}if(I.length>1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspenLine truncated
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>APwhy</title>
<script type="module" crossorigin src="/assets/index-DwfYiTMH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DRUelTBf.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
-119
View File
@@ -1,119 +0,0 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
)
func RandomToken(bytesLen int) (string, error) {
buf := make([]byte, bytesLen)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func RandomPassword(length int) (string, error) {
token, err := RandomToken(length)
if err != nil {
return "", err
}
if len(token) > length {
return token[:length], nil
}
return token, nil
}
func RandomID(prefix string) (string, error) {
buf := make([]byte, 10)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(buf)), nil
}
func HashToken(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func HashPassword(password string) (string, error) {
if len(password) < 8 {
return "", errors.New("password must be at least 8 characters")
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
iterations := uint32(3)
memory := uint32(64 * 1024)
parallelism := uint8(2)
keyLen := uint32(32)
hash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, keyLen)
encoded := fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
memory,
iterations,
parallelism,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
)
return encoded, nil
}
func VerifyPassword(encodedHash, password string) (bool, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return false, errors.New("invalid hash format")
}
if parts[1] != "argon2id" {
return false, errors.New("unsupported hash type")
}
var memory uint32
var iterations uint32
var parallelism uint8
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &parallelism)
if err != nil {
return false, err
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, err
}
decodedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, err
}
calculated := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, uint32(len(decodedHash)))
if len(calculated) != len(decodedHash) {
return false, nil
}
var diff byte
for i := range calculated {
diff |= calculated[i] ^ decodedHash[i]
}
return diff == 0, nil
}
func ParseIntOrDefault(value string, fallback int) int {
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
-127
View File
@@ -1,127 +0,0 @@
package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
Port int
SQLitePath string
DashboardUIBasePath string
APIKeyHeader string
ServiceTokenHeader string
AllowRootRoutePrefix bool
DefaultServiceTimeout time.Duration
CookieSecure bool
CookieDomain string
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
SessionAccessCookie string
SessionRefreshCookie string
FreeRPM int
ProRPM int
BusinessRPM int
FreeMonthlyQuota int
ProMonthlyQuota int
BusinessMonthlyQuota int
UmamiBaseURL string
UmamiAPIKey string
UmamiWebsiteID string
TrustedProxyCIDR string
}
func getenv(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func getenvInt(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func getenvBool(key string, fallback bool) bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if value == "" {
return fallback
}
return value == "1" || value == "true" || value == "yes"
}
func normalizePathPrefix(value, fallback string) string {
v := strings.TrimSpace(value)
if v == "" {
return fallback
}
if !strings.HasPrefix(v, "/") {
v = "/" + v
}
if len(v) > 1 && strings.HasSuffix(v, "/") {
v = strings.TrimSuffix(v, "/")
}
return v
}
func normalizeSQLitePath(value string) string {
v := strings.TrimSpace(value)
if v == "" {
v = "./data/apwhy.sqlite"
}
if v == ":memory:" {
return v
}
if strings.HasPrefix(v, "file:") {
return v
}
if filepath.IsAbs(v) {
return "file:" + v
}
abs, err := filepath.Abs(v)
if err != nil {
return "file:" + v
}
return "file:" + abs
}
func Load() Config {
return Config{
Port: getenvInt("APWHY_PORT", getenvInt("PORT", 3001)),
SQLitePath: normalizeSQLitePath(getenv("SQLITE_DB_PATH", getenv("DATABASE_URL", "./data/apwhy.sqlite"))),
DashboardUIBasePath: normalizePathPrefix(getenv("DASHBOARD_UI_BASE_PATH", "/"), "/"),
APIKeyHeader: strings.ToLower(getenv("API_KEY_HEADER", "x-api-key")),
ServiceTokenHeader: strings.ToLower(getenv("SERVICE_TOKEN_HEADER", "x-apwhy-service-token")),
AllowRootRoutePrefix: getenvBool("ALLOW_ROOT_ROUTE_PREFIX", false),
DefaultServiceTimeout: time.Duration(
getenvInt("DEFAULT_SERVICE_TIMEOUT_MS", 8000),
) * time.Millisecond,
CookieSecure: getenvBool("COOKIE_SECURE", false),
CookieDomain: getenv("COOKIE_DOMAIN", ""),
AccessTokenTTL: time.Duration(getenvInt("ACCESS_TOKEN_TTL_MINUTES", 15)) * time.Minute,
RefreshTokenTTL: time.Duration(getenvInt("REFRESH_TOKEN_TTL_HOURS", 168)) * time.Hour,
SessionAccessCookie: getenv("SESSION_ACCESS_COOKIE", "apwhy_access"),
SessionRefreshCookie: getenv("SESSION_REFRESH_COOKIE", "apwhy_refresh"),
FreeRPM: getenvInt("FREE_RPM", 60),
ProRPM: getenvInt("PRO_RPM", 600),
BusinessRPM: getenvInt("BUSINESS_RPM", 3000),
FreeMonthlyQuota: getenvInt("FREE_MONTHLY_QUOTA", 1000),
ProMonthlyQuota: getenvInt("PRO_MONTHLY_QUOTA", 50000),
BusinessMonthlyQuota: getenvInt("BUSINESS_MONTHLY_QUOTA", 300000),
UmamiBaseURL: strings.TrimRight(getenv("UMAMI_BASE_URL", ""), "/"),
UmamiAPIKey: getenv("UMAMI_API_KEY", ""),
UmamiWebsiteID: getenv("UMAMI_WEBSITE_ID", ""),
TrustedProxyCIDR: getenv("TRUSTED_PROXY_CIDR", ""),
}
}
@@ -1,85 +0,0 @@
package gateway
import (
"bytes"
"io"
"net/http"
"net/url"
"strings"
)
func BuildTargetURL(upstreamURL, routePrefix, requestPath, rawQuery string) (string, error) {
base, err := url.Parse(upstreamURL)
if err != nil {
return "", err
}
trimmedPrefix := routePrefix
if trimmedPrefix == "/" {
trimmedPrefix = ""
}
withoutPrefix := requestPath
if trimmedPrefix != "" && strings.HasPrefix(requestPath, trimmedPrefix) {
withoutPrefix = strings.TrimPrefix(requestPath, trimmedPrefix)
}
if !strings.HasPrefix(withoutPrefix, "/") {
withoutPrefix = "/" + withoutPrefix
}
base.Path = strings.TrimRight(base.Path, "/") + withoutPrefix
base.RawQuery = rawQuery
return base.String(), nil
}
func ProxyRequest(client *http.Client, w http.ResponseWriter, r *http.Request, targetURL string, headers map[string]string) (int, []byte, error) {
var body []byte
if r.Body != nil {
defer r.Body.Close()
payload, err := io.ReadAll(r.Body)
if err != nil {
return 0, nil, err
}
body = payload
}
upstreamReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, bytes.NewReader(body))
if err != nil {
return 0, nil, err
}
for key, values := range r.Header {
lower := strings.ToLower(key)
if lower == "host" || lower == "content-length" || lower == "connection" {
continue
}
for _, value := range values {
upstreamReq.Header.Add(key, value)
}
}
for key, value := range headers {
upstreamReq.Header.Set(key, value)
}
res, err := client.Do(upstreamReq)
if err != nil {
return 0, nil, err
}
defer res.Body.Close()
for key, values := range res.Header {
if strings.EqualFold(key, "transfer-encoding") || strings.EqualFold(key, "connection") {
continue
}
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(res.StatusCode)
respBody, err := io.ReadAll(res.Body)
if err != nil {
return res.StatusCode, nil, err
}
_, _ = w.Write(respBody)
return res.StatusCode, respBody, nil
}
@@ -1,49 +0,0 @@
package rbac
type PermissionSeed struct {
Code string
Name string
Description string
}
var PermissionSeeds = []PermissionSeed{
{Code: "services.read", Name: "Read Services", Description: "View service routing and status."},
{Code: "services.write", Name: "Manage Services", Description: "Create and modify protected services."},
{Code: "databases.read", Name: "Read Databases", Description: "View database connectors."},
{Code: "databases.write", Name: "Manage Databases", Description: "Create and modify database connectors."},
{Code: "keys.read", Name: "Read API Keys", Description: "View API keys and plans."},
{Code: "keys.write", Name: "Manage API Keys", Description: "Create and modify API keys."},
{Code: "users.read", Name: "Read Users", Description: "View users and login status."},
{Code: "users.write", Name: "Manage Users", Description: "Create and modify user accounts."},
{Code: "roles.read", Name: "Read Roles", Description: "View roles and permissions."},
{Code: "roles.write", Name: "Manage Roles", Description: "Create and modify roles and permissions."},
{Code: "analytics.read", Name: "Read Analytics", Description: "View ops and traffic analytics."},
{Code: "settings.write", Name: "Manage Settings", Description: "Modify system settings and integrations."},
}
var OwnerPermissionCodes = []string{
"services.read", "services.write",
"databases.read", "databases.write",
"keys.read", "keys.write",
"users.read", "users.write",
"roles.read", "roles.write",
"analytics.read", "settings.write",
}
var AdminPermissionCodes = []string{
"services.read", "services.write",
"databases.read", "databases.write",
"keys.read", "keys.write",
"users.read", "users.write",
"roles.read",
"analytics.read",
}
var ViewerPermissionCodes = []string{
"services.read",
"databases.read",
"keys.read",
"users.read",
"roles.read",
"analytics.read",
}
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more