diff --git a/main.go b/main.go
new file mode 100644
index 0000000..c9c1cad
--- /dev/null
+++ b/main.go
@@ -0,0 +1,1036 @@
+package main
+
+import (
+ library "git.ailur.dev/ailur/fg-library/v3"
+ "os/signal"
+
+ "errors"
+ "io"
+ "log"
+ "mime"
+ "os"
+ "plugin"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+
+ "crypto/tls"
+ "database/sql"
+ "log/slog"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "path/filepath"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-playground/validator/v10"
+ "github.com/google/uuid"
+ "gopkg.in/yaml.v3"
+
+ "github.com/CAFxX/httpcompression"
+ "github.com/CAFxX/httpcompression/contrib/andybalholm/brotli"
+ "github.com/CAFxX/httpcompression/contrib/klauspost/gzip"
+ "github.com/CAFxX/httpcompression/contrib/klauspost/zstd"
+ kpzstd "github.com/klauspost/compress/zstd"
+
+ _ "github.com/lib/pq"
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type Config struct {
+ Global struct {
+ IP string `yaml:"ip" validate:"required,ip_addr"`
+ ServiceDirectory string `yaml:"serviceDirectory" validate:"required"`
+ ResourceDirectory string `yaml:"resourceDirectory" validate:"required"`
+ Compression CompressionSettings `yaml:"compression"`
+ Logging struct {
+ Enabled bool `yaml:"enabled"`
+ File string `yaml:"file" validate:"required_if=Enabled true"`
+ } `yaml:"logging"`
+ Database struct {
+ Type string `yaml:"type" validate:"required,oneof=sqlite postgres"`
+ ConnectionString string `yaml:"connectionString" validate:"required_if=Type postgres"`
+ Path string `yaml:"path" validate:"required_if=Type sqlite"`
+ } `yaml:"database" validate:"required"`
+ Stealth struct {
+ Enabled bool `yaml:"enabled"`
+ Server string `yaml:"server" validate:"required_if=Enabled true"`
+ PHP struct {
+ Enabled bool `yaml:"enabled"`
+ Version string `yaml:"version" validate:"required_if=Enabled true"`
+ } `yaml:"php"`
+ ASPNet bool `yaml:"aspNet"`
+ }
+ } `yaml:"global" validate:"required"`
+ Routes []Route `yaml:"routes"`
+ Services map[string]interface{} `yaml:"services"`
+}
+
+type Route struct {
+ Port string `yaml:"port" validate:"required"`
+ Subdomain string `yaml:"subdomain" validate:"required"`
+ Services []string `yaml:"services"`
+ Paths []struct {
+ Paths []string `yaml:"paths" validate:"required"`
+ Proxy struct {
+ URL string `yaml:"url" validate:"required"`
+ StripPrefix bool `yaml:"stripPrefix"`
+ Headers HeaderSettings `yaml:"headers"`
+ } `yaml:"proxy" validate:"required_without=Static Redirect"`
+ Static struct {
+ Root string `yaml:"root" validate:"required,isDirectory"`
+ DirectoryListing bool `yaml:"directoryListing"`
+ } `yaml:"static" validate:"required_without_all=Proxy Redirect"`
+ Redirect struct {
+ URL string `yaml:"url" validate:"required"`
+ Permanent bool `yaml:"permanent"`
+ } `yaml:"redirect" validate:"required_without_all=Proxy Static"`
+ } `yaml:"paths"`
+ HTTPS struct {
+ CertificatePath string `yaml:"certificate" validate:"required"`
+ KeyPath string `yaml:"key" validate:"required"`
+ } `yaml:"https"`
+ Compression CompressionSettings `yaml:"compression"`
+}
+
+type HeaderSettings struct {
+ Forbid []string `yaml:"forbid"`
+ PreserveServer bool `yaml:"preserveServer"`
+ PreserveXPoweredBy bool `yaml:"preserveXPoweredBy"`
+ PreserveAltSvc bool `yaml:"preserveAltSvc"`
+ PassHost bool `yaml:"passHost"`
+ XForward bool `yaml:"xForward"`
+}
+
+type Service struct {
+ ServiceID uuid.UUID
+ ServiceMetadata library.Service
+ ServiceMainFunc func(*library.ServiceInitializationInformation)
+ Inbox chan library.InterServiceMessage
+}
+
+type CompressionSettings struct {
+ Algorithm string `yaml:"algorithm" validate:"omitempty,oneof=gzip brotli zstd"`
+ Level int `yaml:"level" validate:"omitempty,min=1,max=22"`
+}
+
+type RouterAndCompression struct {
+ Router *chi.Mux
+ Compression CompressionSettings
+}
+
+type PortRouter struct {
+ https struct {
+ enabled bool
+ httpSettings map[string]*tls.Certificate
+ }
+ routers map[string]RouterAndCompression
+}
+
+func NewPortRouter() *PortRouter {
+ return &PortRouter{
+ routers: make(map[string]RouterAndCompression),
+ https: struct {
+ enabled bool
+ httpSettings map[string]*tls.Certificate
+ }{enabled: false, httpSettings: make(map[string]*tls.Certificate)},
+ }
+}
+
+func (pr *PortRouter) Register(router *chi.Mux, compression CompressionSettings, subdomain string, certificate ...*tls.Certificate) {
+ pr.routers[subdomain] = RouterAndCompression{Router: router, Compression: compression}
+ if len(certificate) > 0 {
+ pr.https.enabled = true
+ pr.https.httpSettings[subdomain] = certificate[0]
+ }
+}
+
+func (pr *PortRouter) Router(w http.ResponseWriter, r *http.Request) {
+ host := strings.Split(r.Host, ":")[0]
+ router, ok := pr.routers[host]
+ if !ok {
+ router, ok = pr.routers["none"]
+ }
+
+ if router.Compression.Algorithm != "none" {
+ compressRouter(router.Compression, router.Router).ServeHTTP(w, r)
+ } else {
+ router.Router.ServeHTTP(w, r)
+ }
+}
+
+func (pr *PortRouter) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ cert, ok := pr.https.httpSettings[hello.ServerName]
+ if !ok {
+ return nil, errors.New("certificate not found")
+ } else {
+ return cert, nil
+ }
+}
+
+func (pr *PortRouter) HTTPSEnabled() bool {
+ return pr.https.enabled
+}
+
+func compressRouter(settings CompressionSettings, handler http.Handler) http.Handler {
+ switch settings.Algorithm {
+ case "gzip":
+ encoder, err := gzip.New(gzip.Options{Level: settings.Level})
+ if err != nil {
+ slog.Error("Error creating gzip encoder: " + err.Error())
+ return handler
+ }
+ gzipHandler, err := httpcompression.Adapter(httpcompression.Compressor(gzip.Encoding, 0, encoder))
+ if err != nil {
+ slog.Error("Error creating gzip handler: " + err.Error())
+ return handler
+ }
+ return gzipHandler(handler)
+ case "brotli":
+ encoder, err := brotli.New(brotli.Options{Quality: settings.Level})
+ if err != nil {
+ slog.Error("Error creating brotli encoder: " + err.Error())
+ return handler
+ }
+ brotliHandler, err := httpcompression.Adapter(httpcompression.Compressor(brotli.Encoding, 0, encoder))
+ if err != nil {
+ slog.Error("Error creating brotli handler: " + err.Error())
+ return handler
+ }
+ return brotliHandler(handler)
+ case "zstd":
+ encoder, err := zstd.New(kpzstd.WithEncoderLevel(kpzstd.EncoderLevelFromZstd(settings.Level)))
+ if err != nil {
+ slog.Error("Error creating zstd encoder: " + err.Error())
+ return handler
+ }
+ zstdHandler, err := httpcompression.Adapter(httpcompression.Compressor(zstd.Encoding, 0, encoder))
+ if err != nil {
+ slog.Error("Error creating zstd handler: " + err.Error())
+ return handler
+ }
+ return zstdHandler(handler)
+ default:
+ return handler
+ }
+}
+
+func logger(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ next.ServeHTTP(w, r)
+ slog.Info(r.Method + " " + r.URL.Path)
+ })
+}
+
+func serverChanger(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !config.Global.Stealth.Enabled {
+ if !strings.Contains("Server", w.Header().Get(":X-Preserve-Headers")) {
+ w.Header().Set("Server", "Fulgens HTTP Server")
+ }
+ if !strings.Contains("X-Powered-By", w.Header().Get(":X-Preserve-Headers")) {
+ w.Header().Set("X-Powered-By", "Go net/http")
+ }
+ if !strings.Contains("Alt-Svc", w.Header().Get(":X-Preserve-Headers")) {
+ w.Header().Set("Alt-Svc", "h2=\":443\"; ma=3600")
+ }
+ } else {
+ switch config.Global.Stealth.Server {
+ case "nginx":
+ w.Header().Set("Server", "nginx")
+ }
+
+ var poweredBy strings.Builder
+ if config.Global.Stealth.PHP.Enabled {
+ poweredBy.WriteString("PHP/" + config.Global.Stealth.PHP.Version)
+ }
+
+ if config.Global.Stealth.ASPNet {
+ if poweredBy.Len() > 0 {
+ poweredBy.WriteString(", ")
+ }
+
+ poweredBy.WriteString("ASP.NET")
+ }
+
+ if poweredBy.Len() > 0 {
+ w.Header().Set("X-Powered-By", poweredBy.String())
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func listDirectory(w http.ResponseWriter, r *http.Request, root string, path string) {
+ // Provide a directory listing
+ w.WriteHeader(200)
+ w.Header().Set("Content-Type", "text/html")
+ _, err := w.Write([]byte("
Directory listing
"))
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error writing directory listing: " + err.Error())
+ return
+ }
+ entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(r.URL.Path)))
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error listing directory: " + err.Error())
+ return
+ }
+ for _, entry := range entries {
+ relPath, err := filepath.Rel(root, filepath.Join(root, filepath.FromSlash(r.URL.Path), entry.Name()))
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error getting relative path: " + err.Error())
+ return
+ }
+ _, err = w.Write([]byte("- " + entry.Name() + "
"))
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error writing directory listing: " + err.Error())
+ return
+ }
+ }
+ _, err = w.Write([]byte("
"))
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error writing directory listing: " + err.Error())
+ return
+ }
+}
+
+func parseEndRange(w http.ResponseWriter, file *os.File, end string) {
+ endI64, err := strconv.ParseInt(end, 10, 64)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error parsing range: " + err.Error())
+ return
+ }
+ _, err = file.Seek(-endI64, io.SeekEnd)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error seeking file: " + err.Error())
+ return
+ }
+ _, err = io.Copy(w, file)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error writing file: " + err.Error())
+ return
+ }
+}
+
+func parseBeginningRange(w http.ResponseWriter, file *os.File, beginning string) {
+ beginningI64, err := strconv.ParseInt(beginning, 10, 64)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error parsing range: " + err.Error())
+ return
+ }
+ _, err = file.Seek(beginningI64, io.SeekStart)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error seeking file: " + err.Error())
+ return
+ }
+ _, err = io.Copy(w, file)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error writing file: " + err.Error())
+ return
+ }
+}
+
+func parsePartRange(w http.ResponseWriter, file *os.File, beginning, end string) {
+ beginningI64, err := strconv.ParseInt(beginning, 10, 64)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error parsing range: " + err.Error())
+ return
+ }
+ endI64, err := strconv.ParseInt(end, 10, 64)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error parsing range: " + err.Error())
+ return
+ }
+ _, err = file.Seek(beginningI64, io.SeekStart)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error seeking file: " + err.Error())
+ return
+ }
+ _, err = io.CopyN(w, file, endI64-beginningI64)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error writing file: " + err.Error())
+ return
+ }
+}
+
+func newFileServer(root string, directoryListing bool, path string) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ stat, err := os.Stat(filepath.Join(root, filepath.FromSlash(r.URL.Path)))
+ if err != nil {
+ serverError(w, 404)
+ return
+ }
+
+ if stat.IsDir() {
+ // See if index.html exists
+ _, err := os.Stat(filepath.Join(root, filepath.FromSlash(r.URL.Path), "index.html"))
+ if err != nil {
+ if directoryListing {
+ listDirectory(w, r, root, path)
+ } else {
+ serverError(w, 403)
+ }
+ return
+ } else {
+ // Serve the index.html file
+ r.URL.Path = filepath.Join(r.URL.Path, "index.html")
+ }
+ }
+
+ file, err := os.Open(filepath.Join(root, filepath.FromSlash(r.URL.Path)))
+ if err != nil {
+ serverError(w, 500)
+ return
+ }
+
+ w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(r.URL.Path)))
+
+ if strings.HasPrefix(r.Header.Get("Range"), "bytes=") {
+ // Parse the range header. If there is an int-int, seek to the first int then return a limitedReader.
+ // If there is an int-, seek to the first int and return the rest of the file.
+ // If there is an -int, seek to the end of the file minus int and return the last int bytes.
+ for _, item := range strings.Split(strings.TrimPrefix(r.Header.Get("Range"), "bytes="), ", ") {
+ if strings.Contains(item, "-") {
+ beginning := strings.Split(item, "-")[0]
+ end := strings.Split(item, "-")[1]
+ if beginning == "" {
+ parseEndRange(w, file, end)
+ } else if end == "" {
+ parseBeginningRange(w, file, beginning)
+ } else {
+ parsePartRange(w, file, beginning, end)
+ }
+ } else {
+ serverError(w, 416)
+ return
+ }
+ }
+ } else {
+ _, err = io.Copy(w, file)
+ if err != nil {
+ serverError(w, 500)
+ slog.Error("Error writing file: " + err.Error())
+ return
+ }
+
+ err = file.Close()
+ if err != nil {
+ slog.Error("Error closing file: " + err.Error())
+ }
+ }
+ })
+}
+
+func newReverseProxy(uri *url.URL, headerSettings HeaderSettings) http.Handler {
+ proxy := httputil.NewSingleHostReverseProxy(uri)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Strip the headers
+ for _, header := range headerSettings.Forbid {
+ r.Header.Del(header)
+ }
+ if !headerSettings.PassHost {
+ r.Host = uri.Host
+ }
+ if !headerSettings.XForward {
+ r.Header["X-Forwarded-For"] = nil
+ } else {
+ r.Header.Set("X-Forwarded-Host", r.Host)
+ if r.URL.Scheme != "" {
+ r.Header.Set("X-Forwarded-Proto", r.URL.Scheme)
+ } else {
+ r.Header.Set("X-Forwarded-Proto", "http")
+ }
+ }
+
+ // Set the preserve headers which will be stripped by the server changer
+ var xPreserveHeaders strings.Builder
+
+ if headerSettings.PreserveServer {
+ xPreserveHeaders.WriteString("Server")
+ }
+
+ if headerSettings.PreserveXPoweredBy {
+ if xPreserveHeaders.Len() > 0 {
+ xPreserveHeaders.WriteString(", ")
+ }
+ xPreserveHeaders.WriteString("X-Powered-By")
+ }
+
+ if headerSettings.PreserveAltSvc {
+ if xPreserveHeaders.Len() > 0 {
+ xPreserveHeaders.WriteString(", ")
+ }
+ xPreserveHeaders.WriteString("Alt-Svc")
+ }
+
+ w.Header().Set(":X-Preserve-Headers", xPreserveHeaders.String())
+
+ proxy.ServeHTTP(w, r)
+ })
+}
+
+func serverError(w http.ResponseWriter, status int) {
+ w.Header().Set("Content-Type", "text/html")
+ w.WriteHeader(status)
+ if !config.Global.Stealth.Enabled {
+ _, err := w.Write([]byte("" + strconv.Itoa(status) + " " + http.StatusText(status) + "
Fulgens HTTP Server"))
+ if err != nil {
+ slog.Error("Error writing " + strconv.Itoa(status) + ": " + err.Error())
+ return
+ }
+ } else {
+ switch config.Global.Stealth.Server {
+ case "nginx":
+ _, err := w.Write([]byte("" + strconv.Itoa(status) + " " + http.StatusText(status) + "\n\n" + strconv.Itoa(status) + " " + http.StatusText(status) + "
\n
nginx/1.27.2\n\n\n"))
+ if err != nil {
+ slog.Error("Error writing " + strconv.Itoa(status) + ": " + err.Error())
+ return
+ }
+ case "net/http":
+ _, err := w.Write([]byte(strconv.Itoa(status) + " " + http.StatusText(status)))
+ if err != nil {
+ slog.Error("Error writing " + strconv.Itoa(status) + ": " + err.Error())
+ return
+ }
+ }
+ }
+}
+
+var (
+ validate *validator.Validate
+ lock sync.RWMutex
+ config Config
+ registeredServices = make(map[string]Service)
+ activeServices = make(map[uuid.UUID]Service)
+ portRouters = make(map[string]*PortRouter)
+ broadcastService = uuid.MustParse("00000000-0000-0000-0000-000000000000")
+ databaseService = uuid.MustParse("00000000-0000-0000-0000-000000000001")
+ logService = uuid.MustParse("00000000-0000-0000-0000-000000000002")
+ blobService = uuid.MustParse("00000000-0000-0000-0000-000000000003")
+ authService = uuid.MustParse("00000000-0000-0000-0000-000000000004")
+)
+
+func loadTLSCertificate(certificatePath, keyPath string) (*tls.Certificate, error) {
+ certificate, err := tls.LoadX509KeyPair(certificatePath, keyPath)
+ if err != nil {
+ return nil, err
+ } else {
+ return &certificate, nil
+ }
+}
+
+var globalPGConn *sql.DB
+
+func createPgSchema(id uuid.UUID) error {
+ _, err := globalPGConn.Exec("CREATE SCHEMA IF NOT EXISTS \"" + id.String() + "\"")
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func svInit(message library.InterServiceMessage) {
+ var dummyInfo = &library.ServiceInitializationInformation{Outbox: activeServices[message.ServiceID].Inbox}
+ if !activeServices[message.ServiceID].ServiceMetadata.Permissions.Database {
+ message.Respond(library.Unauthorized, errors.New("database access not permitted"), dummyInfo)
+ return
+ }
+
+ var db library.Database
+ switch config.Global.Database.Type {
+ case "sqlite":
+ pluginConn, err := sql.Open("sqlite3", filepath.Join(config.Global.Database.Path, message.ServiceID.String()+".db"))
+ if err != nil {
+ message.Respond(library.InternalError, err, dummyInfo)
+ return
+ }
+
+ db = library.Database{
+ DB: pluginConn,
+ DBType: library.Sqlite,
+ }
+ case "postgres":
+ err := createPgSchema(message.ServiceID)
+ if err != nil {
+ message.Respond(library.InternalError, err, dummyInfo)
+ return
+ }
+
+ connectionString := config.Global.Database.ConnectionString
+ if strings.Contains(config.Global.Database.ConnectionString, "?") {
+ connectionString += "&"
+ } else {
+ connectionString += "?"
+ }
+ connectionString += "search_path=\"" + message.ServiceID.String() + "\""
+
+ pluginConn, err := sql.Open("postgres", connectionString)
+ if err != nil {
+ message.Respond(library.InternalError, err, dummyInfo)
+ return
+ }
+
+ db = library.Database{
+ DB: pluginConn,
+ DBType: library.Postgres,
+ }
+ default:
+ message.Respond(library.InternalError, errors.New("database type not supported"), dummyInfo)
+ return
+ }
+
+ err := db.DB.Ping()
+ if err != nil {
+ message.Respond(library.InternalError, err, dummyInfo)
+ return
+ }
+
+ message.Respond(library.Success, db, dummyInfo)
+}
+
+func tryAuthAccess(message library.InterServiceMessage) {
+ serviceMetadata, ok := activeServices[message.ServiceID]
+ var dummyInfo = &library.ServiceInitializationInformation{Outbox: serviceMetadata.Inbox}
+ if !ok || !serviceMetadata.ServiceMetadata.Permissions.Authenticate {
+ message.Respond(library.Unauthorized, errors.New("authentication not permitted"), dummyInfo)
+ return
+ }
+
+ service, ok := activeServices[authService]
+ if !ok {
+ message.Respond(library.InternalError, errors.New("authentication service not found"), dummyInfo)
+ return
+ }
+
+ service.Inbox <- message
+}
+
+func tryStorageAccess(message library.InterServiceMessage) {
+ serviceMetadata, ok := activeServices[message.ServiceID]
+ var dummyInfo = &library.ServiceInitializationInformation{Outbox: serviceMetadata.Inbox}
+ if !ok || !serviceMetadata.ServiceMetadata.Permissions.BlobStorage {
+ message.Respond(library.Unauthorized, errors.New("storage access not permitted"), dummyInfo)
+ return
+ }
+
+ service, ok := activeServices[blobService]
+ if !ok {
+ message.Respond(library.InternalError, errors.New("storage service not found"), dummyInfo)
+ return
+ }
+
+ service.Inbox <- message
+}
+
+func tryLogger(message library.InterServiceMessage) {
+ // Logger service
+ service, ok := activeServices[message.ServiceID]
+ if ok {
+ switch message.MessageType {
+ case 0:
+ // Log message
+ slog.Info(strings.ToLower(service.ServiceMetadata.Name) + " says: " + message.Message.(string))
+ case 1:
+ // Warn message
+ slog.Warn(strings.ToLower(service.ServiceMetadata.Name) + " warns: " + message.Message.(string))
+ case 2:
+ // Error message
+ slog.Error(strings.ToLower(service.ServiceMetadata.Name) + " complains: " + message.Message.(string))
+ case 3:
+ // Fatal message
+ slog.Error(strings.ToLower(service.ServiceMetadata.Name) + "'s dying wish: " + message.Message.(string))
+ os.Exit(1)
+ }
+ }
+}
+
+func processInterServiceMessage(listener library.Listener) {
+ for {
+ message := listener.AcceptMessage()
+ switch message.ForServiceID {
+ case broadcastService:
+ // Broadcast message
+ for _, service := range activeServices {
+ service.Inbox <- message
+ }
+ case databaseService:
+ // Database service
+ switch message.MessageType {
+ case 0:
+ svInit(message)
+ }
+ case logService:
+ tryLogger(message)
+ case blobService:
+ tryStorageAccess(message)
+ case authService:
+ tryAuthAccess(message)
+ default:
+ serviceMetadata, ok := activeServices[message.ServiceID]
+ var dummyInfo = &library.ServiceInitializationInformation{Outbox: serviceMetadata.Inbox}
+ if !ok || !serviceMetadata.ServiceMetadata.Permissions.InterServiceCommunication {
+ message.Respond(library.Unauthorized, errors.New("inter-service communication not permitted"), dummyInfo)
+ } else {
+ service, ok := activeServices[message.ForServiceID]
+ if !ok {
+ message.Respond(library.BadRequest, errors.New("service not found"), dummyInfo)
+ }
+ service.Inbox <- message
+ }
+ }
+ }
+}
+
+func parseConfig(path string) Config {
+ // Register the custom validators
+ validate = validator.New()
+
+ // Register the custom isDirectory validator
+ err := validate.RegisterValidation("isDirectory", func(fl validator.FieldLevel) bool {
+ // Check if it exists
+ fileInfo, err := os.Stat(fl.Field().String())
+ if err != nil {
+ return false
+ }
+
+ // Check if it is a directory
+ return fileInfo.IsDir()
+ })
+
+ if err != nil {
+ slog.Error("Error registering custom validator: " + err.Error())
+ os.Exit(1)
+ }
+
+ // Parse the configuration file
+ configFile, err := os.Open(path)
+ if err != nil {
+ slog.Error("Error reading configuration file: " + err.Error())
+ os.Exit(1)
+ }
+
+ // Parse the configuration file
+ decoder := yaml.NewDecoder(configFile)
+ err = decoder.Decode(&config)
+ if err != nil {
+ slog.Error("Error parsing configuration file: " + err.Error())
+ os.Exit(1)
+ }
+
+ // Validate the configuration
+ err = validate.Struct(config)
+ if err != nil {
+ slog.Error("Invalid configuration: " + err.Error())
+ os.Exit(1)
+ }
+
+ // Check if we are logging to a file
+ if config.Global.Logging != (Config{}.Global.Logging) && config.Global.Logging.Enabled {
+ // Check if the log file is set
+ logFilePath := config.Global.Logging.File
+
+ // Set the log file
+ logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ slog.Error("Error opening log file: " + err.Error())
+ os.Exit(1)
+ }
+
+ log.SetOutput(io.MultiWriter(os.Stdout, logFile))
+ }
+
+ return config
+}
+
+func checkHTTPS(route Route, subdomainRouter *chi.Mux, compressionSettings CompressionSettings) {
+ // Check if HTTPS is enabled
+ if route.HTTPS.KeyPath != "" && route.HTTPS.CertificatePath != "" {
+ certificate, err := loadTLSCertificate(route.HTTPS.CertificatePath, route.HTTPS.KeyPath)
+ if err != nil {
+ slog.Error("Error loading TLS certificate: " + err.Error())
+ os.Exit(1)
+ }
+ portRouters[route.Port].Register(subdomainRouter, compressionSettings, route.Subdomain, certificate)
+ } else {
+ portRouters[route.Port].Register(subdomainRouter, compressionSettings, route.Subdomain)
+ }
+}
+
+func checkServices(route Route, globalOutbox chan library.InterServiceMessage, subdomainRouter *chi.Mux) {
+ // Check the services
+ if route.Services != nil {
+ // Iterate through the services
+ for _, service := range route.Services {
+ // Check if the service is registered
+ registeredService, ok := registeredServices[service]
+ if ok {
+ // Check if the service is already active
+ _, ok := activeServices[registeredService.ServiceMetadata.ServiceID]
+ if ok {
+ slog.Error("Service with ID " + service + " is already active, will not activate again")
+ os.Exit(1)
+ } else {
+ // Initialize the service
+ initializeService(registeredService, globalOutbox, subdomainRouter, &route.Subdomain)
+ }
+ } else {
+ slog.Warn("Service with ID " + service + " is not registered")
+ }
+ }
+ }
+}
+
+func iterateThroughSubdomains(globalOutbox chan library.InterServiceMessage) {
+ for _, route := range config.Routes {
+ var compressionSettings CompressionSettings
+ if route.Compression != (CompressionSettings{}) {
+ compressionSettings = route.Compression
+ } else {
+ compressionSettings = config.Global.Compression
+ }
+
+ // Create the subdomain router
+ subdomainRouter := chi.NewRouter()
+ subdomainRouter.NotFound(func(w http.ResponseWriter, r *http.Request) {
+ serverError(w, 404)
+ })
+
+ // Set the port router
+ _, ok := portRouters[route.Port]
+ if !ok {
+ portRouters[route.Port] = NewPortRouter()
+ }
+
+ // Check if HTTPS is enabled
+ checkHTTPS(route, subdomainRouter, compressionSettings)
+
+ // Check the services
+ checkServices(route, globalOutbox, subdomainRouter)
+
+ // Iterate through the paths
+ for _, pathBlock := range route.Paths {
+ for _, path := range pathBlock.Paths {
+ if pathBlock.Static.Root != "" {
+ // Serve the static directory
+ rawPath := strings.TrimSuffix(path, "*")
+ subdomainRouter.Handle(path, http.StripPrefix(rawPath, newFileServer(pathBlock.Static.Root, pathBlock.Static.DirectoryListing, rawPath)))
+ slog.Info("Serving static directory " + pathBlock.Static.Root + " on subdomain " + route.Subdomain + " with pattern " + path)
+ } else if pathBlock.Proxy.URL != "" {
+ // Create the proxy
+ parsedURL, err := url.Parse(pathBlock.Proxy.URL)
+ if err != nil {
+ slog.Error("Error parsing URL: " + err.Error())
+ os.Exit(1)
+ }
+ if pathBlock.Proxy.StripPrefix {
+ subdomainRouter.Handle(path, http.StripPrefix(strings.TrimSuffix(path, "*"), newReverseProxy(parsedURL, pathBlock.Proxy.Headers)))
+ } else {
+ subdomainRouter.Handle(path, newReverseProxy(parsedURL, pathBlock.Proxy.Headers))
+ }
+ } else if pathBlock.Redirect.URL != "" {
+ // Set the code
+ code := http.StatusFound
+ if pathBlock.Redirect.Permanent {
+ code = http.StatusMovedPermanently
+ }
+
+ // Create the redirect
+ subdomainRouter.Handle(path, http.RedirectHandler(pathBlock.Redirect.URL, code))
+ }
+ }
+ }
+ }
+}
+
+func registerServices() (err error) {
+ err = filepath.Walk(config.Global.ServiceDirectory, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if info.IsDir() || filepath.Ext(path) != ".fgs" {
+ return nil
+ }
+
+ // Open the service
+ service, err := plugin.Open(path)
+ if err != nil {
+ return err
+ }
+
+ // Load the service information
+ serviceInformation, err := service.Lookup("ServiceInformation")
+ if err != nil {
+ return errors.New("service " + path + " lacks necessary service information")
+ }
+
+ // Load the main function
+ mainFunc, err := service.Lookup("Main")
+ if err != nil {
+ return errors.New("service " + path + " lacks necessary main function")
+ }
+
+ // Register the service
+ var inbox = make(chan library.InterServiceMessage, 1)
+ lock.Lock()
+ registeredServices[strings.ToLower(serviceInformation.(*library.Service).Name)] = Service{
+ ServiceID: serviceInformation.(*library.Service).ServiceID,
+ Inbox: inbox,
+ ServiceMetadata: *serviceInformation.(*library.Service),
+ ServiceMainFunc: mainFunc.(func(*library.ServiceInitializationInformation)),
+ }
+ lock.Unlock()
+
+ // Log the service registration
+ slog.Info("Service " + strings.ToLower(serviceInformation.(*library.Service).Name) + " registered with ID " + serviceInformation.(*library.Service).ServiceID.String())
+
+ return nil
+ })
+
+ return err
+}
+
+func initializeService(service Service, globalOutbox chan library.InterServiceMessage, subdomainRouter *chi.Mux, subdomain *string) {
+ // Get the plugin from the map
+ slog.Info("Activating service " + strings.ToLower(service.ServiceMetadata.Name) + " with ID " + service.ServiceMetadata.ServiceID.String())
+
+ serviceInitializationInformation := library.NewServiceInitializationInformation(nil, globalOutbox, service.Inbox, nil, config.Services[strings.ToLower(service.ServiceMetadata.Name)].(map[string]interface{}), nil)
+
+ serviceInitializationInformation.Service = &service.ServiceMetadata
+
+ // Check if they want a resource directory
+ if service.ServiceMetadata.Permissions.Resources {
+ serviceInitializationInformation.ResourceDir = os.DirFS(filepath.Join(config.Global.ResourceDirectory, service.ServiceMetadata.ServiceID.String()))
+ }
+
+ // Check if they want a router
+ if service.ServiceMetadata.Permissions.Router {
+ serviceInitializationInformation.Router = subdomainRouter
+ serviceInitializationInformation.Domain = subdomain
+ }
+
+ // Add the service to the active services
+ lock.Lock()
+ activeServices[service.ServiceMetadata.ServiceID] = service
+ lock.Unlock()
+
+ // Call the main function
+ service.ServiceMainFunc(serviceInitializationInformation)
+
+ // Log the service activation
+ slog.Info("Service " + strings.ToLower(service.ServiceMetadata.Name) + " activated with ID " + service.ServiceMetadata.ServiceID.String())
+}
+
+func main() {
+ // Parse the configuration file
+ if len(os.Args) < 2 {
+ info, err := os.Stat("config.yaml")
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ slog.Error("No configuration file provided")
+ os.Exit(1)
+ } else {
+ slog.Error("Error reading configuration file: " + err.Error())
+ os.Exit(1)
+ }
+ }
+
+ if info.IsDir() {
+ slog.Error("No configuration file provided")
+ os.Exit(1)
+ }
+
+ config = parseConfig("config.yaml")
+ } else {
+ config = parseConfig(os.Args[1])
+ }
+
+ // If we are using sqlite, create the database directory if it does not exist
+ if config.Global.Database.Type == "sqlite" {
+ err := os.MkdirAll(config.Global.Database.Path, 0755)
+ if err != nil {
+ slog.Error("Error creating database directory: " + err.Error())
+ os.Exit(1)
+ }
+ } else {
+ // Set the global database connection
+ var err error
+ globalPGConn, err = sql.Open("postgres", config.Global.Database.ConnectionString)
+ if err != nil {
+ slog.Error("Error connecting to database: " + err.Error())
+ os.Exit(1)
+ }
+ }
+
+ // Walk through the service directory and load the plugins
+ err := registerServices()
+ if err != nil {
+ slog.Error("Error registering services: " + err.Error())
+ os.Exit(1)
+ }
+
+ var globalOutbox = make(chan library.InterServiceMessage, 1)
+
+ // Initialize the service discovery, health-check, and logging services
+ // Since these are core services, always allocate them the service IDs 0, 1, and 2
+ // These are not dynamically loaded, as they are integral to the system functioning
+ go processInterServiceMessage(library.NewListener(globalOutbox))
+
+ // Start the storage service
+ // initializeService(registeredServices["storage"], globalOutbox, nil, nil)
+
+ // Iterate through the subdomains and create the routers as well as the compression levels and service maps
+ iterateThroughSubdomains(globalOutbox)
+
+ // Start the servers
+ slog.Info("Starting servers")
+ for port, router := range portRouters {
+ if !router.HTTPSEnabled() {
+ go func() {
+ // Start the HTTP server
+ err = http.ListenAndServe(config.Global.IP+":"+port, logger(serverChanger(http.HandlerFunc(router.Router))))
+ slog.Error("Error starting server: " + err.Error())
+ os.Exit(1)
+ }()
+ } else {
+ // Create the TLS server
+ server := &http.Server{
+ Addr: config.Global.IP + ":" + port,
+ Handler: logger(serverChanger(http.HandlerFunc(router.Router))),
+ TLSConfig: &tls.Config{
+ GetCertificate: router.GetCertificate,
+ },
+ }
+
+ go func() {
+ // Start the TLS server
+ err = server.ListenAndServeTLS("", "")
+ slog.Error("Error starting HTTPS server: " + err.Error())
+ os.Exit(1)
+ }()
+ }
+ }
+
+ slog.Info("Servers started. Fulgens is now running. Press Ctrl+C to stop the server.")
+
+ // Wait for a signal to stop the server
+ signalChannel := make(chan os.Signal, 1)
+ signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM)
+ <-signalChannel
+}