mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Databases hosted outside Containr can now be registered alongside
managed ones. The connection is probed before storing (protocol-level
for postgres/redis-family via real drivers, TCP dial for the rest),
credentials are encrypted at rest and never returned, and external rows
skip managed lifecycle actions, backups, and Docker metrics.
- POST /databases/register-external — probe + create
- POST /databases/test-connection — ad-hoc probe (ok/latency_ms/error)
- POST /databases/{id}/test-connection — re-probe stored credentials
- provider/external fields on database services; privileged reads get a
composed connection URL with loopback hosts rewritten to the caller's
- CLI: databases register + test-connection; MCP: 3 new tools
- UI: managed/external toggle in the create modal, external badge and
host display, per-row test button, backups section hidden
- Migration widens the plan CHECK to admit 'external'
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
320 lines
9.6 KiB
Go
320 lines
9.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"containr/internal/database/sqlcdb"
|
|
"containr/internal/secrets"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-redis/redis/v8"
|
|
)
|
|
|
|
// externalDatabaseRequest is the shared payload for registering an external
|
|
// database and for ad-hoc connection tests.
|
|
type externalDatabaseRequest struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type" binding:"required"`
|
|
Host string `json:"host" binding:"required"`
|
|
Port int `json:"port"`
|
|
Database string `json:"database"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
SSL bool `json:"ssl"`
|
|
}
|
|
|
|
var externalDefaultPorts = map[string]int{
|
|
"postgresql": 5432,
|
|
"mysql": 3306,
|
|
"mariadb": 3306,
|
|
"mongodb": 27017,
|
|
"redis": 6379,
|
|
"dragonfly": 6379,
|
|
"clickhouse": 9000,
|
|
}
|
|
|
|
// externalConnectionURL composes a DSN for display/bind use. The password is
|
|
// embedded only on privileged reads — the value is decrypted at that point.
|
|
func externalConnectionURL(dbType string, ext *DatabaseExternalRef, password string) string {
|
|
host := net.JoinHostPort(ext.Host, fmt.Sprintf("%d", ext.Port))
|
|
creds := ""
|
|
if ext.Username != "" {
|
|
creds = url.QueryEscape(ext.Username)
|
|
if password != "" {
|
|
creds += ":" + url.QueryEscape(password)
|
|
}
|
|
creds += "@"
|
|
}
|
|
switch dbType {
|
|
case "postgresql":
|
|
u := fmt.Sprintf("postgres://%s%s/%s", creds, host, ext.Database)
|
|
if ext.SSL {
|
|
u += "?sslmode=require"
|
|
}
|
|
return u
|
|
case "mysql", "mariadb":
|
|
return fmt.Sprintf("mysql://%s%s/%s", creds, host, ext.Database)
|
|
case "mongodb":
|
|
u := fmt.Sprintf("mongodb://%s%s/%s", creds, host, ext.Database)
|
|
if ext.SSL {
|
|
u += "?tls=true"
|
|
}
|
|
return u
|
|
case "redis", "dragonfly":
|
|
scheme := "redis"
|
|
if ext.SSL {
|
|
scheme = "rediss"
|
|
}
|
|
// Redis auth is password-only; username support is ACL-era.
|
|
if password != "" {
|
|
creds = ":" + url.QueryEscape(password) + "@"
|
|
if ext.Username != "" {
|
|
creds = url.QueryEscape(ext.Username) + creds
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s://%s%s", scheme, creds, host)
|
|
case "clickhouse":
|
|
return fmt.Sprintf("clickhouse://%s%s/%s", creds, host, ext.Database)
|
|
}
|
|
return fmt.Sprintf("%s://%s%s", dbType, creds, host)
|
|
}
|
|
|
|
// probeExternalDatabase verifies reachability. postgres and redis-family get
|
|
// real protocol checks via the vendored drivers; everything else gets a TCP
|
|
// dial — enough to catch typos and firewall issues without new dependencies.
|
|
func probeExternalDatabase(ctx context.Context, req externalDatabaseRequest) (time.Duration, error) {
|
|
addr := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
|
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
|
start := time.Now()
|
|
|
|
switch req.Type {
|
|
case "postgresql":
|
|
sslmode := "disable"
|
|
if req.SSL {
|
|
sslmode = "require"
|
|
}
|
|
dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s&connect_timeout=5",
|
|
url.QueryEscape(req.Username), url.QueryEscape(req.Password), addr,
|
|
url.QueryEscape(req.Database), sslmode)
|
|
conn, err := sql.Open("postgres", dsn)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer conn.Close()
|
|
if err := conn.PingContext(ctx); err != nil {
|
|
return 0, err
|
|
}
|
|
case "redis", "dragonfly":
|
|
client := redis.NewClient(&redis.Options{
|
|
Addr: addr,
|
|
Username: req.Username,
|
|
Password: req.Password,
|
|
DialTimeout: 5 * time.Second,
|
|
ReadTimeout: 5 * time.Second,
|
|
TLSConfig: redisTLSConfig(req.SSL, req.Host),
|
|
})
|
|
defer client.Close()
|
|
if err := client.Ping(ctx).Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
default:
|
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
conn.Close()
|
|
}
|
|
return time.Since(start), nil
|
|
}
|
|
|
|
func redisTLSConfig(enabled bool, host string) *tls.Config {
|
|
if !enabled {
|
|
return nil
|
|
}
|
|
return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
|
}
|
|
|
|
// handleTestDatabaseConnection probes arbitrary connection parameters — used
|
|
// by the register form's "test" button and by agents before registering.
|
|
func (h *DatabaseHandler) TestDatabaseConnection(c *gin.Context) {
|
|
if _, ok := requireAuthenticatedUserID(c); !ok {
|
|
return
|
|
}
|
|
var req externalDatabaseRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
if err := normalizeExternalRequest(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
|
defer cancel()
|
|
latency, err := probeExternalDatabase(ctx, req)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
|
}
|
|
|
|
// TestDatabaseConnectionByID re-probes a registered external database using
|
|
// its stored (decrypted) credentials.
|
|
func (h *DatabaseHandler) TestDatabaseConnectionByID(c *gin.Context) {
|
|
userID, ok := requireAuthenticatedUserID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
databaseID := c.Param("id")
|
|
userID = h.effectiveUserID(c, userID, databaseID)
|
|
|
|
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
|
|
ID: databaseID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found", "code": "NOT_FOUND"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
|
|
return
|
|
}
|
|
if row.Provider != "external" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "test-connection applies to external databases", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
|
|
req := externalDatabaseRequest{
|
|
Type: row.Type,
|
|
Host: databaseNullString(row.ExternalHost),
|
|
Port: int(row.ExternalPort.Int32),
|
|
Database: databaseNullString(row.ExternalName),
|
|
Username: databaseNullString(row.ExternalUsername),
|
|
Password: secrets.Decrypt(row.ExternalPassword),
|
|
SSL: row.ExternalSsl,
|
|
}
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
|
defer cancel()
|
|
latency, probeErr := probeExternalDatabase(ctx, req)
|
|
if probeErr != nil {
|
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": probeErr.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency.Milliseconds()})
|
|
}
|
|
|
|
func normalizeExternalRequest(req *externalDatabaseRequest) error {
|
|
req.Type = normalizeDatabaseType(req.Type)
|
|
if _, ok := supportedDatabaseTypes[req.Type]; !ok {
|
|
return fmt.Errorf("%w: %s", errUnsupportedDatabaseType, req.Type)
|
|
}
|
|
req.Host = strings.TrimSpace(req.Host)
|
|
if req.Host == "" {
|
|
return errors.New("host is required")
|
|
}
|
|
if req.Port <= 0 {
|
|
req.Port = externalDefaultPorts[req.Type]
|
|
}
|
|
if req.Port <= 0 || req.Port > 65535 {
|
|
return errors.New("port must be 1-65535")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RegisterExternalDatabase records a database hosted elsewhere — same list,
|
|
// same bind surface, no container. The connection is probed first so typos
|
|
// fail at registration, not at the first service bind.
|
|
func (h *DatabaseHandler) RegisterExternalDatabase(c *gin.Context) {
|
|
userID, ok := requireAuthenticatedUserID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req externalDatabaseRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required", "code": "VALIDATION"})
|
|
return
|
|
}
|
|
if err := normalizeExternalRequest(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"})
|
|
return
|
|
}
|
|
|
|
probeCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
|
defer cancel()
|
|
if _, err := probeExternalDatabase(probeCtx, req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": fmt.Sprintf("Connection check failed: %v", err),
|
|
"code": "DEPENDENCY_UNAVAILABLE",
|
|
})
|
|
return
|
|
}
|
|
|
|
count, err := h.queries.CountDatabaseServicesByUserAndName(c.Request.Context(), sqlcdb.CountDatabaseServicesByUserAndNameParams{
|
|
UserID: userID,
|
|
Name: req.Name,
|
|
})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate database name"})
|
|
return
|
|
}
|
|
if count > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "database name already exists", "code": "CONFLICT"})
|
|
return
|
|
}
|
|
|
|
databaseID := generateDatabaseID(req.Name)
|
|
now := sql.NullTime{Time: time.Now(), Valid: true}
|
|
encPassword := ""
|
|
if req.Password != "" {
|
|
encPassword = secrets.Encrypt(req.Password)
|
|
}
|
|
if err := h.queries.CreateExternalDatabaseService(c.Request.Context(), sqlcdb.CreateExternalDatabaseServiceParams{
|
|
ID: databaseID,
|
|
UserID: userID,
|
|
Name: req.Name,
|
|
Type: req.Type,
|
|
Version: "",
|
|
Plan: "external",
|
|
Region: "external",
|
|
ExternalHost: sql.NullString{String: req.Host, Valid: true},
|
|
ExternalPort: sql.NullInt32{Int32: int32(req.Port), Valid: true},
|
|
ExternalName: sql.NullString{String: req.Database, Valid: req.Database != ""},
|
|
ExternalUsername: sql.NullString{String: req.Username, Valid: req.Username != ""},
|
|
ExternalPassword: encPassword,
|
|
ExternalSsl: req.SSL,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register database"})
|
|
return
|
|
}
|
|
|
|
LogAudit(userID, "database", databaseID, "register-external", map[string]interface{}{
|
|
"name": req.Name,
|
|
"type": req.Type,
|
|
"host": req.Host,
|
|
})
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": databaseID,
|
|
"message": "External database registered",
|
|
"status": "running",
|
|
})
|
|
}
|