diff --git a/app/config/yaml.go b/app/config/yaml.go new file mode 100644 index 0000000..6dca785 --- /dev/null +++ b/app/config/yaml.go @@ -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 +} diff --git a/app/database/conn.go b/app/database/conn.go index b6958c1..a584cbf 100644 --- a/app/database/conn.go +++ b/app/database/conn.go @@ -3,6 +3,7 @@ package database import ( "fmt" + "git.secnex.io/secnex/gogwapi/config" "git.secnex.io/secnex/gogwapi/utils" "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 { return &DatabaseConfiguration{ Host: utils.GetEnv("DB_HOST", "localhost"), diff --git a/app/go.mod b/app/go.mod index 4dccf08..c346fdc 100644 --- a/app/go.mod +++ b/app/go.mod @@ -4,6 +4,7 @@ go 1.25.3 require ( git.secnex.io/secnex/masterlog v0.1.0 + github.com/goccy/go-yaml v1.18.0 github.com/google/uuid v1.6.0 golang.org/x/crypto v0.45.0 gorm.io/driver/postgres v1.6.0 diff --git a/app/go.sum b/app/go.sum index 3a06b33..4fb65a5 100644 --- a/app/go.sum +++ b/app/go.sum @@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/app/main.go b/app/main.go index 0c02678..9c6acf1 100644 --- a/app/main.go +++ b/app/main.go @@ -1,30 +1,62 @@ package main import ( + "fmt" + "net/http" + + "git.secnex.io/secnex/gogwapi/config" "git.secnex.io/secnex/gogwapi/database" "git.secnex.io/secnex/gogwapi/models" + "git.secnex.io/secnex/gogwapi/proxy" "git.secnex.io/secnex/masterlog" ) func main() { - pseudonymizer := masterlog.NewPseudonymizerFromString("1234567890") - // pseudonymizer := masterlog.NewPseudonymizerFromEnv("MASTERLOG_SECRET") - masterlog.SetPseudonymizer(pseudonymizer) - masterlog.AddSensitiveFields("user_id", "password", "email") - masterlog.SetLevel(masterlog.LevelInfo) - masterlog.AddEncoder(&masterlog.JSONEncoder{}) + config, err := config.LoadYAMLConfig("../config.yaml") + if err != nil { + masterlog.Error("failed to load config", map[string]interface{}{"error": err.Error()}) + return + } + + 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{}{ &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}) if err := dbConfig.Connect(allModels...); err != nil { masterlog.Error("failed to connect to database", map[string]interface{}{"error": err.Error()}) return } 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 + } } diff --git a/app/models/domain.go b/app/models/domain.go new file mode 100644 index 0000000..34ba05e --- /dev/null +++ b/app/models/domain.go @@ -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" +} diff --git a/app/models/endpoint.go b/app/models/endpoint.go index e23de32..33c2cb3 100644 --- a/app/models/endpoint.go +++ b/app/models/endpoint.go @@ -9,10 +9,15 @@ import ( type Endpoint struct { 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"` UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_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 { diff --git a/app/models/endpoint_group.go b/app/models/endpoint_group.go new file mode 100644 index 0000000..3ccae01 --- /dev/null +++ b/app/models/endpoint_group.go @@ -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" +} diff --git a/app/models/group.go b/app/models/group.go new file mode 100644 index 0000000..1216f18 --- /dev/null +++ b/app/models/group.go @@ -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" +} diff --git a/app/models/record.go b/app/models/record.go new file mode 100644 index 0000000..6da820f --- /dev/null +++ b/app/models/record.go @@ -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" +} diff --git a/app/proxy/reverse.go b/app/proxy/reverse.go new file mode 100644 index 0000000..8f989a7 --- /dev/null +++ b/app/proxy/reverse.go @@ -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) +} \ No newline at end of file diff --git a/app/repositories/domain.go b/app/repositories/domain.go new file mode 100644 index 0000000..1f71803 --- /dev/null +++ b/app/repositories/domain.go @@ -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 +} diff --git a/app/schema/domain.go b/app/schema/domain.go new file mode 100644 index 0000000..a325ee2 --- /dev/null +++ b/app/schema/domain.go @@ -0,0 +1,5 @@ +package schema + +type DomainSchema struct { + Name string `json:"name"` +}