2025-01-08 18:31:48 +00:00
package main
import (
library "git.ailur.dev/ailur/fg-library/v3"
"errors"
"io"
"log"
"mime"
"os"
2025-01-08 19:07:56 +00:00
"os/signal"
2025-01-08 18:31:48 +00:00
"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 ( "<html><body><h2>Directory listing</h2><ul>" ) )
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 ( "<li><a href=\"" + path + strings . TrimPrefix ( relPath , "./" ) + "\">" + entry . Name ( ) + "</a></li>" ) )
if err != nil {
serverError ( w , 500 )
slog . Error ( "Error writing directory listing: " + err . Error ( ) )
return
}
}
_ , err = w . Write ( [ ] byte ( "</ul></body></html>" ) )
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 ( "<html><body><h2>" + strconv . Itoa ( status ) + " " + http . StatusText ( status ) + "</h2><span>Fulgens HTTP Server</span></body></html>" ) )
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 ( "<html><head><title>" + strconv . Itoa ( status ) + " " + http . StatusText ( status ) + "</title></head>\n<body>\n<center><h1>" + strconv . Itoa ( status ) + " " + http . StatusText ( status ) + "</h1></center>\n<hr><center>nginx/1.27.2</center>\n\n\n</body></html>" ) )
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
}