feat(docker): Add Dockerfile and compose file

This commit is contained in:
Björn Benouarets
2025-11-29 03:09:40 +01:00
parent 08055398c4
commit 3c08a2cb25
13 changed files with 381 additions and 9 deletions

75
app/config/yaml.go Normal file
View File

@@ -0,0 +1,75 @@
package config
import (
"encoding/json"
"os"
"github.com/goccy/go-yaml"
)
type YAMLConfig struct {
Server Server `yaml:"server"`
Database Database `yaml:"database"`
Targets []Target `yaml:"targets"`
}
type Server struct {
Port int `yaml:"port" env:"SERVER_PORT"`
}
type Database struct {
Host string `yaml:"host" env:"DB_HOST"`
Port string `yaml:"port" env:"DB_PORT"`
User string `yaml:"user" env:"DB_USER"`
Password string `yaml:"password" env:"DB_PASSWORD"`
Database string `yaml:"database" env:"DB_DATABASE"`
}
type Target struct {
Name string `yaml:"name"`
Group *string `yaml:"group"`
Records []Record `yaml:"records"`
}
type Record struct {
Record string `yaml:"record"`
Endpoints []Endpoint `yaml:"endpoints"`
}
type Endpoint struct {
Name string `yaml:"name"`
Path string `yaml:"path"`
URL string `yaml:"url"`
}
func LoadYAMLConfig(path string) (*YAMLConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var config YAMLConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
func (c *YAMLConfig) JSON() string {
json, err := json.Marshal(c)
if err != nil {
return ""
}
return string(json)
}
func (c *YAMLConfig) Map() *map[string]interface{} {
jsonData, err := json.Marshal(c)
if err != nil {
return nil
}
var mapData map[string]interface{}
if err := json.Unmarshal(jsonData, &mapData); err != nil {
return nil
}
return &mapData
}

View File

@@ -3,6 +3,7 @@ package database
import ( import (
"fmt" "fmt"
"git.secnex.io/secnex/gogwapi/config"
"git.secnex.io/secnex/gogwapi/utils" "git.secnex.io/secnex/gogwapi/utils"
"git.secnex.io/secnex/masterlog" "git.secnex.io/secnex/masterlog"
@@ -30,6 +31,16 @@ func NewDatabaseConfiguration(host, port, user, password, database string) *Data
} }
} }
func NewDatabaseConfigurationFromEnvAndConfig(config *config.YAMLConfig) *DatabaseConfiguration {
return &DatabaseConfiguration{
Host: config.Database.Host,
Port: config.Database.Port,
User: config.Database.User,
Password: config.Database.Password,
Database: config.Database.Database,
}
}
func NewDatabaseConfigurationFromEnv() *DatabaseConfiguration { func NewDatabaseConfigurationFromEnv() *DatabaseConfiguration {
return &DatabaseConfiguration{ return &DatabaseConfiguration{
Host: utils.GetEnv("DB_HOST", "localhost"), Host: utils.GetEnv("DB_HOST", "localhost"),

View File

@@ -4,6 +4,7 @@ go 1.25.3
require ( require (
git.secnex.io/secnex/masterlog v0.1.0 git.secnex.io/secnex/masterlog v0.1.0
github.com/goccy/go-yaml v1.18.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.45.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0

View File

@@ -3,6 +3,8 @@ git.secnex.io/secnex/masterlog v0.1.0/go.mod h1:OnDlwEzdkKMnqY+G5O9kHdhoJ6fH1llb
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View File

@@ -1,30 +1,62 @@
package main package main
import ( import (
"fmt"
"net/http"
"git.secnex.io/secnex/gogwapi/config"
"git.secnex.io/secnex/gogwapi/database" "git.secnex.io/secnex/gogwapi/database"
"git.secnex.io/secnex/gogwapi/models" "git.secnex.io/secnex/gogwapi/models"
"git.secnex.io/secnex/gogwapi/proxy"
"git.secnex.io/secnex/masterlog" "git.secnex.io/secnex/masterlog"
) )
func main() { func main() {
pseudonymizer := masterlog.NewPseudonymizerFromString("1234567890") config, err := config.LoadYAMLConfig("../config.yaml")
// pseudonymizer := masterlog.NewPseudonymizerFromEnv("MASTERLOG_SECRET") if err != nil {
masterlog.SetPseudonymizer(pseudonymizer) masterlog.Error("failed to load config", map[string]interface{}{"error": err.Error()})
masterlog.AddSensitiveFields("user_id", "password", "email") return
masterlog.SetLevel(masterlog.LevelInfo) }
masterlog.AddEncoder(&masterlog.JSONEncoder{})
targets := config.Targets
masterlog.Info("Targets loaded", map[string]interface{}{"count": len(targets)})
for _, target := range targets {
targetDescription := target.Name
if target.Group != nil {
targetDescription = fmt.Sprintf("[%s] %s", *target.Group, target.Name)
}
masterlog.Info(targetDescription, map[string]interface{}{"target": target.Name, "group": *target.Group, "records": len(target.Records)})
}
allModels := []interface{}{ allModels := []interface{}{
&models.Endpoint{}, &models.Endpoint{},
} }
dbConfig := database.NewDatabaseConfigurationFromEnv() dbConfig := database.NewDatabaseConfigurationFromEnvAndConfig(config)
masterlog.Info("Connecting to database", map[string]interface{}{"host": dbConfig.Host, "port": dbConfig.Port, "database": dbConfig.Database}) masterlog.Info("Connecting to database", map[string]interface{}{"host": dbConfig.Host, "port": dbConfig.Port, "database": dbConfig.Database})
if err := dbConfig.Connect(allModels...); err != nil { if err := dbConfig.Connect(allModels...); err != nil {
masterlog.Error("failed to connect to database", map[string]interface{}{"error": err.Error()}) masterlog.Error("failed to connect to database", map[string]interface{}{"error": err.Error()})
return return
} }
masterlog.Info("Connected to database!") masterlog.Info("Connected to database!")
masterlog.Info("Starting server", map[string]interface{}{"port": ":3000"})
// Initialize reverse proxy
reverseProxy := proxy.NewReverseProxy(config)
// Setup HTTP server
mux := http.NewServeMux()
mux.HandleFunc("/", reverseProxy.ServeHTTP)
// Start server
addr := fmt.Sprintf(":%d", config.Server.Port)
masterlog.Info("Starting reverse proxy server", map[string]interface{}{
"port": config.Server.Port,
"address": addr,
})
if err := http.ListenAndServe(addr, mux); err != nil {
masterlog.Error("server failed to start", map[string]interface{}{"error": err.Error()})
return
}
} }

24
app/models/domain.go Normal file
View File

@@ -0,0 +1,24 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Domain struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null;unique" json:"name"`
Verified bool `gorm:"not null;default:false" json:"verified"`
VerificationToken string `gorm:"not null;unique" json:"verification_token"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
Records []Record `gorm:"foreignKey:DomainID" json:"-"`
}
func (Domain) TableName() string {
return "domains"
}

View File

@@ -9,10 +9,15 @@ import (
type Endpoint struct { type Endpoint struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
URL string `gorm:"not null" json:"url"` Path string `gorm:"not null;default:/" json:"path"`
RecordID uuid.UUID `gorm:"not null;uniqueIndex:idx_endpoint_record_id" json:"record_id"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"` UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
Record Record `gorm:"foreignKey:RecordID" json:"-"`
EndpointGroups []EndpointGroup `gorm:"foreignKey:EndpointID" json:"-"`
} }
func (Endpoint) TableName() string { func (Endpoint) TableName() string {

View File

@@ -0,0 +1,24 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type EndpointGroup struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
GroupID uuid.UUID `gorm:"not null;uniqueIndex:idx_endpoint_group_group_id_endpoint_id" json:"group_id"`
EndpointID uuid.UUID `gorm:"not null;uniqueIndex:idx_endpoint_group_group_id_endpoint_id" json:"endpoint_id"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
Group Group `gorm:"foreignKey:GroupID" json:"-"`
Endpoint Endpoint `gorm:"foreignKey:EndpointID" json:"-"`
}
func (EndpointGroup) TableName() string {
return "endpoint_groups"
}

23
app/models/group.go Normal file
View File

@@ -0,0 +1,23 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Group struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null;unique" json:"name"`
Description *string `json:"description"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
EndpointGroups []EndpointGroup `gorm:"foreignKey:GroupID" json:"-"`
}
func (Group) TableName() string {
return "groups"
}

25
app/models/record.go Normal file
View File

@@ -0,0 +1,25 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Record struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Record string `gorm:"not null;uniqueIndex:idx_record_domain_id" json:"record"`
DomainID uuid.UUID `gorm:"not null;uniqueIndex:idx_record_domain_id" json:"domain_id"`
CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
Domain Domain `gorm:"foreignKey:DomainID" json:"-"`
Endpoints []Endpoint `gorm:"foreignKey:RecordID" json:"-"`
}
func (Record) TableName() string {
return "records"
}

104
app/proxy/reverse.go Normal file
View File

@@ -0,0 +1,104 @@
package proxy
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"git.secnex.io/secnex/gogwapi/config"
"git.secnex.io/secnex/masterlog"
)
type ReverseProxy struct {
config *config.YAMLConfig
}
func NewReverseProxy(cfg *config.YAMLConfig) *ReverseProxy {
return &ReverseProxy{
config: cfg,
}
}
type TargetMatch struct {
URL *url.URL
Endpoint *config.Endpoint
}
func (rp *ReverseProxy) findTarget(host string, path string) (*TargetMatch, error) {
for _, target := range rp.config.Targets {
for _, record := range target.Records {
expectedHost := fmt.Sprintf("%s.%s", record.Record, target.Name)
if host == expectedHost {
for _, endpoint := range record.Endpoints {
if endpoint.Path == "/" {
targetURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
return &TargetMatch{URL: targetURL, Endpoint: &endpoint}, nil
}
if strings.HasPrefix(path, endpoint.Path) {
targetURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
return &TargetMatch{URL: targetURL, Endpoint: &endpoint}, nil
}
}
}
}
}
return nil, fmt.Errorf("no target found for host: %s, path: %s", host, path)
}
func (rp *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
host := r.Host
path := r.URL.Path
targetMatch, err := rp.findTarget(host, path)
if err != nil {
masterlog.Error("target not found", map[string]interface{}{
"host": host,
"path": path,
"error": err.Error(),
})
http.Error(w, "Service not found", http.StatusNotFound)
return
}
targetURL := targetMatch.URL
endpoint := targetMatch.Endpoint
// Transform the path: remove the endpoint prefix if it's not "/"
var transformedPath string
if endpoint.Path == "/" {
transformedPath = path
} else {
transformedPath = strings.TrimPrefix(path, endpoint.Path)
if transformedPath == "" {
transformedPath = "/"
}
}
masterlog.Info("proxying request", map[string]interface{}{
"host": host,
"original_path": path,
"transformed_path": transformedPath,
"endpoint_prefix": endpoint.Path,
"target": targetURL.String(),
})
proxy := httputil.NewSingleHostReverseProxy(targetURL)
// Create a new request with the transformed path
r.URL.Host = targetURL.Host
r.URL.Scheme = targetURL.Scheme
r.URL.Path = transformedPath
r.Header.Set("X-Forwarded-Host", host)
r.Header.Set("X-Forwarded-For", r.RemoteAddr)
r.Header.Set("X-Forwarded-Path", path) // Original path for debugging
proxy.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,41 @@
package repositories
import (
"git.secnex.io/secnex/gogwapi/models"
"gorm.io/gorm"
)
type DomainRepository interface {
Create(domain *models.Domain) error
Get(id string) (*models.Domain, error)
Update(domain *models.Domain) error
Delete(id string) error
}
type domainRepository struct {
db *gorm.DB
}
func NewDomainRepository(db *gorm.DB) DomainRepository {
return &domainRepository{db: db}
}
func (r *domainRepository) Create(domain *models.Domain) error {
return r.db.Create(domain).Error
}
func (r *domainRepository) Get(name string) (*models.Domain, error) {
var domain models.Domain
if err := r.db.Where("name = ?", name).First(&domain).Error; err != nil {
return nil, err
}
return &domain, nil
}
func (r *domainRepository) Update(domain *models.Domain) error {
return r.db.Save(domain).Error
}
func (r *domainRepository) Delete(id string) error {
return r.db.Delete(&models.Domain{}, id).Error
}

5
app/schema/domain.go Normal file
View File

@@ -0,0 +1,5 @@
package schema
type DomainSchema struct {
Name string `json:"name"`
}