diff --git a/.gitignore b/.gitignore index 91b2461..9db9b7b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,8 @@ mantrae # Go workspace file go.work *.json - +*.db +*.db-shm +*.db-wal +.envrc dist/ diff --git a/README.md b/README.md index 7e185a4..4654493 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ ## Features -- **Domain Management**: Easily manage your domains and assign them to specific hosts via the web interface. +- **Simplified UI**: A clean and intuitive interface that keeps the complexity to a minimum. - **Router Configuration**: Create and manage Traefik routers with custom rules, entrypoints, and middleware configurations. - **Middleware Management**: Add middlewares to your routers, including rate limiting, authentication, and more. - **Service Status**: Monitor the status of your services and see their health information. -- **Simplified UI**: A clean and intuitive interface that keeps the complexity to a minimum. +- **DNS Providers**: Support for multiple DNS providers (currently Cloudflare and PowerDNS) for automatic DNS record updates. ## Getting Started @@ -21,20 +21,16 @@ ### Installation -1. Download the latest release from the [releases page](https://github.com/MizuchiLabs/mantrae/releases) +1. Generate a random secret with `openssl rand -hex 32` -1. Extract the downloaded file +1. Use docker `docker run --name mantrae -e SECRET= -d -p 3000:3000 ghcr.io/mizuchilabs/mantrae:latest` -1. Run the application `./mantrae` +1. Or use the example docker-compose.yml file to run mantrae and traefik together 1. **Access the Web UI**: Open your web browser and navigate to `http://localhost:3000` -1. Or use docker `docker run --name mantrae -d -p 3000:3000 ghcr.io/mizuchilabs/mantrae:latest` - -1. You can also use the example docker-compose.yml file to run mantrae and traefik together - -1. Use the admin password, which will be printed in the logs after the first start +1. Use the admin password, which will be printed in the logs after the first start. You won't be able to access the password afterwards! ## Usage @@ -88,6 +84,13 @@ providers: endpoint: "" ``` +## Roadmap + +- Add support for multiple DNS providers and better handling. +- Backup and restore functionality (S3) +- Support multiple database providers (currently only supports SQLite) +- Better credentials management and multi-user support. + ## Contributing Contributions are welcome! Please feel free to submit issues, fork the repository, and create pull requests. diff --git a/docker-compose.yml b/docker-compose.yml index 9cb5972..4a20378 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,8 @@ services: mantrae: image: ghcr.io/mizuchilabs/mantrae:latest container_name: mantrae + environment: + - SECRET= # generate a secret with openssl rand -hex 32 command: - --url=http://traefik:8080 # if traefik is running on the same host - --username=admin # use if you want to enable basicauth on traefik diff --git a/go.mod b/go.mod index 8968fd6..fe767dc 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/joeig/go-powerdns/v3 v3.12.0 github.com/lmittmann/tint v1.0.5 + github.com/mattn/go-sqlite3 v1.14.22 golang.org/x/crypto v0.26.0 golang.org/x/net v0.28.0 sigs.k8s.io/yaml v1.4.0 diff --git a/go.sum b/go.sum index e78580d..29008e8 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= diff --git a/internal/api/auth.go b/internal/api/auth.go index 7a666d1..792cdc5 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -1,9 +1,9 @@ package api import ( + "os" "time" - "github.com/MizuchiLabs/mantrae/pkg/util" "github.com/golang-jwt/jwt/v5" ) @@ -23,26 +23,18 @@ func GenerateJWT(username string) (string, error) { } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - var secret util.Credentials - if err := secret.GetCreds(); err != nil { - return "", err - } - return token.SignedString(secret.Secret) + return token.SignedString([]byte(os.Getenv("SECRET"))) } // ValidateJWT validates a JWT token func ValidateJWT(tokenString string) (*Claims, error) { claims := &Claims{} - var secret util.Credentials - if err := secret.GetCreds(); err != nil { - return nil, err - } token, err := jwt.ParseWithClaims( tokenString, claims, func(token *jwt.Token) (interface{}, error) { - return secret.Secret, nil + return []byte(os.Getenv("SECRET")), nil }, ) if err != nil { diff --git a/internal/api/handler.go b/internal/api/handler.go index 83a5588..bbc057a 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -2,13 +2,14 @@ package api import ( + "context" "encoding/json" "fmt" "net/http" + "strconv" - "github.com/MizuchiLabs/mantrae/pkg/dns" + "github.com/MizuchiLabs/mantrae/internal/db" "github.com/MizuchiLabs/mantrae/pkg/traefik" - "github.com/MizuchiLabs/mantrae/pkg/util" ) // Helper function to write JSON response @@ -32,26 +33,26 @@ func updateName[K comparable, V any](m map[K]V, oldName, newName K) { } } +// Authentication ------------------------------------------------------------- + +// Login verifies the user credentials func Login(w http.ResponseWriter, r *http.Request) { - var creds util.Credentials + var creds db.Credential if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, "Failed to decode credentials", http.StatusBadRequest) return } if creds.Username == "" || creds.Password == "" { - http.Error(w, "username and password cannot be empty", http.StatusBadRequest) + http.Error(w, "Username or password cannot be empty", http.StatusBadRequest) return } - var valid util.Credentials - if err := valid.GetCreds(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if creds.Username != valid.Username || creds.Password != valid.Password { - http.Error(w, "invalid username or password", http.StatusUnauthorized) + if _, err := db.Query.ValidateAuth(context.Background(), db.ValidateAuthParams{ + Username: creds.Username, + Password: creds.Password, + }); err != nil { + http.Error(w, "Invalid username or password", http.StatusUnauthorized) return } @@ -67,431 +68,244 @@ func Login(w http.ResponseWriter, r *http.Request) { func VerifyToken(w http.ResponseWriter, r *http.Request) { tokenString := r.Header.Get("Authorization") if len(tokenString) < 7 { - http.Error(w, "token cannot be empty", http.StatusBadRequest) + http.Error(w, "Token cannot be empty", http.StatusBadRequest) return } _, err := ValidateJWT(tokenString[7:]) if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) + http.Error(w, "Invalid token", http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) } +// Profiles ------------------------------------------------------------------- + // GetProfiles returns all profiles but without the dynamic data func GetProfiles(w http.ResponseWriter, r *http.Request) { - data := make(map[string]traefik.Profile, len(traefik.ProfileData.Profiles)) - for name, profile := range traefik.ProfileData.Profiles { - data[name] = traefik.Profile{ - Name: profile.Name, - URL: profile.URL, - Username: profile.Username, - Password: profile.Password, - TLS: profile.TLS, - } + profiles, err := db.Query.ListProfiles(context.Background()) + if err != nil { + http.Error(w, "Failed to get profiles", http.StatusInternalServerError) + return } - writeJSON(w, data) + writeJSON(w, profiles) } // GetProfile returns a single profile func GetProfile(w http.ResponseWriter, r *http.Request) { - profileName := r.PathValue("name") - if profileName == "" { - http.Error(w, "profile name cannot be empty", http.StatusBadRequest) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "Profile not found", http.StatusNotFound) return } - profile, ok := traefik.ProfileData.Profiles[profileName] - if !ok { - http.Error(w, "profile not found", http.StatusNotFound) + profile, err := db.Query.GetProfileByID(context.Background(), id) + if err != nil { + http.Error(w, "Profile not found", http.StatusNotFound) return } - writeJSON(w, profile) } // CreateProfile creates a new profile func CreateProfile(w http.ResponseWriter, r *http.Request) { - var profile traefik.Profile + var profile db.CreateProfileParams if err := json.NewDecoder(r.Body).Decode(&profile); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, "Failed to decode profile", http.StatusBadRequest) return } - if err := profile.Verify(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + data, err := db.Query.CreateProfile(context.Background(), profile) + if err != nil { + http.Error(w, "Failed to create profile", http.StatusInternalServerError) return } - traefik.ProfileData.Profiles[profile.Name] = profile - if err := traefik.ProfileData.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - go traefik.GetTraefikConfig() - writeJSON(w, traefik.ProfileData.Profiles) + writeJSON(w, data) } // UpdateProfile updates a single profile func UpdateProfile(w http.ResponseWriter, r *http.Request) { - if len(traefik.ProfileData.Profiles) == 0 { - http.Error(w, "no profiles configured", http.StatusBadRequest) - return - } - - profileName := r.PathValue("name") - if profileName == "" { - http.Error(w, "profile name cannot be empty", http.StatusBadRequest) - return - } - - var profile traefik.Profile + var profile db.UpdateProfileParams if err := json.NewDecoder(r.Body).Decode(&profile); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, "Failed to decode profile", http.StatusBadRequest) return } - if err := profile.Verify(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - updateName(traefik.ProfileData.Profiles, profileName, profile.Name) - - traefik.ProfileData.Profiles[profile.Name] = profile - if err := traefik.ProfileData.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + data, err := db.Query.UpdateProfile(context.Background(), profile) + if err != nil { + http.Error(w, "Failed to update profile", http.StatusInternalServerError) return } go traefik.GetTraefikConfig() - writeJSON(w, profile) + writeJSON(w, data) } // DeleteProfile deletes a single profile func DeleteProfile(w http.ResponseWriter, r *http.Request) { - profileName := r.PathValue("name") - if profileName == "" { - http.Error(w, "profile name cannot be empty", http.StatusBadRequest) - return - } - - if _, ok := traefik.ProfileData.Profiles[profileName]; !ok { - http.Error(w, "profile not found", http.StatusNotFound) - return - } - - delete(traefik.ProfileData.Profiles, profileName) - if err := traefik.ProfileData.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, traefik.ProfileData.Profiles) -} - -func GetProviders(w http.ResponseWriter, r *http.Request) { - var providers dns.Providers - if err := providers.Load(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, providers.Providers) -} - -func UpdateProvider(w http.ResponseWriter, r *http.Request) { - var provider dns.Provider - if err := json.NewDecoder(r.Body).Decode(&provider); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if err := provider.Verify(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - providerName := r.PathValue("name") - if providerName == "" { - http.Error(w, "provider name cannot be empty", http.StatusBadRequest) - return - } - - var providers dns.Providers - if err := providers.Load(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if providers.Providers == nil { - providers.Providers = make(map[string]dns.Provider) - } - - updateName(providers.Providers, providerName, provider.Name) - providers.Providers[provider.Name] = provider - if err := providers.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - writeJSON(w, providers.Providers) -} - -func DeleteProvider(w http.ResponseWriter, r *http.Request) { - providerName := r.PathValue("name") - if providerName == "" { - http.Error(w, "provider name cannot be empty", http.StatusBadRequest) - return - } - - var providers dns.Providers - if err := providers.Load(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - delete(providers.Providers, providerName) - if err := providers.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - writeJSON(w, providers.Providers) -} - -// UpdateRouter updates or creates a router -func UpdateRouter(w http.ResponseWriter, r *http.Request) { - var router traefik.Router - if err := json.NewDecoder(r.Body).Decode(&router); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if err := router.Verify(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - profileName := r.PathValue("profile") - routerName := r.PathValue("router") - if profileName == "" || routerName == "" { - http.Error(w, "profile or router name cannot be empty", http.StatusBadRequest) - return - } - - profile, ok := traefik.ProfileData.Profiles[profileName] - if !ok { - http.Error(w, "profile not found", http.StatusNotFound) - return - } - - // Initialize the Routers map if it is nil - if profile.Dynamic.Routers == nil { - profile.Dynamic.Routers = make(map[string]traefik.Router) - } - - // If the router name is being changed, delete the old entry - updateName(profile.Dynamic.Routers, routerName, router.Name) - - profile.Dynamic.Routers[router.Name] = router - traefik.ProfileData.Profiles[profileName] = profile // Update the profile in the map - if err := traefik.ProfileData.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - go dns.UpdateDNS() - writeJSON(w, profile) -} - -// DeleteRouter deletes a single router and it's services -func DeleteRouter(w http.ResponseWriter, r *http.Request) { - profileName := r.PathValue("profile") - routerName := r.PathValue("router") - if profileName == "" || routerName == "" { - http.Error(w, "profile or router name cannot be empty", http.StatusBadRequest) - return - } - - profile, ok := traefik.ProfileData.Profiles[profileName] - if !ok { - http.Error(w, "profile not found", http.StatusNotFound) - return - } - - // Delete the DNS record - go dns.DeleteDNS(profile.Dynamic.Routers[routerName]) - - delete(profile.Dynamic.Routers, routerName) - delete(profile.Dynamic.Services, routerName) - if err := traefik.ProfileData.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - writeJSON(w, profile) -} - -// UpdateService updates or creates a service -func UpdateService(w http.ResponseWriter, r *http.Request) { - var service traefik.Service - if err := json.NewDecoder(r.Body).Decode(&service); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if err := service.Verify(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - profileName := r.PathValue("profile") - serviceName := r.PathValue("service") - if profileName == "" || serviceName == "" { - http.Error(w, "profile or service name cannot be empty", http.StatusBadRequest) - return - } - - profile, ok := traefik.ProfileData.Profiles[profileName] - if !ok { - http.Error(w, "profile not found", http.StatusNotFound) - return - } - - if profile.Dynamic.Services == nil { - profile.Dynamic.Services = make(map[string]traefik.Service) - } - - updateName(profile.Dynamic.Services, serviceName, service.Name) - - profile.Dynamic.Services[service.Name] = service - traefik.ProfileData.Profiles[profileName] = profile // Update the profile in the map - if err := traefik.ProfileData.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - writeJSON(w, profile) -} - -// DeleteService deletes a single service and its router -func DeleteService(w http.ResponseWriter, r *http.Request) { - profileName := r.PathValue("profile") - serviceName := r.PathValue("service") - if profileName == "" || serviceName == "" { - http.Error(w, "profile or service name cannot be empty", http.StatusBadRequest) - return - } - - profile, ok := traefik.ProfileData.Profiles[profileName] - if !ok { - http.Error(w, "profile not found", http.StatusNotFound) - return - } - - delete(profile.Dynamic.Services, serviceName) - delete(profile.Dynamic.Routers, serviceName) - if err := traefik.ProfileData.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - writeJSON(w, profile) -} - -// UpdateMiddleware updates or creates a middleware -func UpdateMiddleware(w http.ResponseWriter, r *http.Request) { - var middleware traefik.Middleware - if err := json.NewDecoder(r.Body).Decode(&middleware); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - profileName := r.PathValue("profile") - middlewareName := r.PathValue("middleware") - if profileName == "" || middlewareName == "" { - http.Error(w, "profile or middleware name cannot be empty", http.StatusBadRequest) - return - } - - profile, ok := traefik.ProfileData.Profiles[profileName] - if !ok { - http.Error(w, "profile not found", http.StatusNotFound) - return - } - - if profile.Dynamic.Middlewares == nil { - profile.Dynamic.Middlewares = make(map[string]traefik.Middleware) - } - - updateName(profile.Dynamic.Middlewares, middlewareName, middleware.Name) - - if err := middleware.Verify(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - profile.Dynamic.Middlewares[middleware.Name] = middleware - traefik.ProfileData.Profiles[profileName] = profile // Update the profile in the map - if err := traefik.ProfileData.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - writeJSON(w, profile) -} - -// DeleteMiddleware deletes a single middleware and it's services -func DeleteMiddleware(w http.ResponseWriter, r *http.Request) { - profileName := r.PathValue("profile") - middlewareName := r.PathValue("middleware") - if profileName == "" || middlewareName == "" { - http.Error(w, "profile or middleware name cannot be empty", http.StatusBadRequest) - return - } - - profile, ok := traefik.ProfileData.Profiles[profileName] - if !ok { - http.Error(w, "profile not found", http.StatusNotFound) - return - } - - delete(profile.Dynamic.Middlewares, middlewareName) - if err := traefik.ProfileData.Save(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - writeJSON(w, profile) -} - -// GetConfig returns the traefik config for a single profile -func GetConfig(w http.ResponseWriter, r *http.Request) { - profileName := r.PathValue("name") - if profileName == "" { - http.Error(w, "profile name cannot be empty", http.StatusBadRequest) - return - } - - profile, ok := traefik.ProfileData.Profiles[profileName] - if !ok { - http.Error(w, "profile not found", http.StatusNotFound) - return - } - - yamlConfig, err := traefik.GenerateConfig(profile.Dynamic) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, "Profile not found", http.StatusNotFound) + return + } + + if err := db.Query.DeleteProfileByID(context.Background(), id); err != nil { + http.Error(w, "Failed to delete profile", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// Providers ------------------------------------------------------------------ + +// GetProviders returns all providers +func GetProviders(w http.ResponseWriter, r *http.Request) { + providers, err := db.Query.ListProviders(context.Background()) + if err != nil { + http.Error(w, "Failed to get providers", http.StatusInternalServerError) + return + } + + writeJSON(w, providers) +} + +// GetProvider returns a single provider +func GetProvider(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "Provider not found", http.StatusNotFound) + return + } + provider, err := db.Query.GetProviderByID(context.Background(), id) + if err != nil { + http.Error(w, "Provider not found", http.StatusNotFound) + return + } + + writeJSON(w, provider) +} + +// CreateProvider creates a new provider +func CreateProvider(w http.ResponseWriter, r *http.Request) { + var provider db.CreateProviderParams + if err := json.NewDecoder(r.Body).Decode(&provider); err != nil { + http.Error(w, "Failed to decode provider", http.StatusBadRequest) + return + } + + data, err := db.Query.CreateProvider(context.Background(), provider) + if err != nil { + http.Error(w, "Failed to create provider", http.StatusInternalServerError) + return + } + + writeJSON(w, data) +} + +// UpdateProvider updates a single provider +func UpdateProvider(w http.ResponseWriter, r *http.Request) { + var provider db.UpdateProviderParams + if err := json.NewDecoder(r.Body).Decode(&provider); err != nil { + http.Error(w, "Failed to decode provider", http.StatusBadRequest) + return + } + + data, err := db.Query.UpdateProvider(context.Background(), provider) + if err != nil { + http.Error(w, "Failed to update profile", http.StatusInternalServerError) + return + } + + writeJSON(w, data) +} + +// DeleteProvider deletes a single provider +func DeleteProvider(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "Profile not found", http.StatusNotFound) + return + } + if err := db.Query.DeleteProviderByID(context.Background(), id); err != nil { + http.Error(w, "Failed to delete provider", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// Config --------------------------------------------------------------------- + +// GetConfig returns the config for a single profile +func GetConfig(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "Profile not found", http.StatusNotFound) + return + } + config, err := db.Query.GetConfigByProfileID(context.Background(), id) + if err != nil { + http.Error(w, "Profile not found", http.StatusNotFound) + return + } + data, err := traefik.DecodeConfig(config) + if err != nil { + http.Error(w, "Failed to decode config", http.StatusInternalServerError) + return + } + + writeJSON(w, data) +} + +func UpdateConfig(w http.ResponseWriter, r *http.Request) { + var config traefik.Dynamic + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + http.Error(w, "Failed to decode config", http.StatusBadRequest) + return + } + + if err := traefik.UpdateConfig(config.ProfileID, &config); err != nil { + http.Error(w, "Failed to update config", http.StatusInternalServerError) + return + } + + writeJSON(w, config) +} + +// GetTraefikConfig returns the traefik config +func GetTraefikConfig(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "Profile not found", http.StatusNotFound) + return + } + config, err := db.Query.GetConfigByProfileID(context.Background(), id) + if err != nil { + http.Error(w, "Profile not found", http.StatusNotFound) + return + } + data, err := traefik.DecodeConfig(config) + if err != nil { + http.Error(w, "Failed to decode config", http.StatusInternalServerError) + return + } + + yamlConfig, err := traefik.GenerateConfig(*data) + if err != nil { + http.Error(w, "Failed to generate traefik config", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/yaml") w.Header(). - Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.yaml", profileName)) + Set("Content-Disposition", fmt.Sprintf("attachment; filename=dynamic.yaml")) if _, err := w.Write(yamlConfig); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, "Failed to write traefik config", http.StatusInternalServerError) return } } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index b9f2a8d..ba6e9de 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,12 +1,13 @@ package api import ( + "context" "log/slog" "net/http" "strings" "time" - "github.com/MizuchiLabs/mantrae/pkg/util" + "github.com/MizuchiLabs/mantrae/internal/db" ) // statusRecorder is a wrapper around http.ResponseWriter to capture the status code @@ -78,13 +79,13 @@ func BasicAuth(next http.Handler) http.Handler { return } - var valid util.Credentials - if err := valid.GetCreds(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + creds, err := db.Query.GetCredentialByUsername(context.Background(), username) + if err != nil { + http.Error(w, "User not found", http.StatusNotFound) return } - if username != valid.Username || password != valid.Password { + if password != creds.Password { w.WriteHeader(http.StatusUnauthorized) return } diff --git a/internal/api/routes.go b/internal/api/routes.go index a507582..32e3ebc 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -14,26 +14,22 @@ func Routes() http.Handler { mux.HandleFunc("POST /api/login", Login) mux.HandleFunc("POST /api/verify", VerifyToken) - mux.HandleFunc("GET /api/profiles", JWT(GetProfiles)) - mux.HandleFunc("GET /api/profile/{name}", JWT(GetProfile)) - mux.HandleFunc("POST /api/profiles", JWT(CreateProfile)) - mux.HandleFunc("PUT /api/profiles/{name}", JWT(UpdateProfile)) - mux.HandleFunc("DELETE /api/profiles/{name}", JWT(DeleteProfile)) + mux.HandleFunc("GET /api/profile", JWT(GetProfiles)) + mux.HandleFunc("GET /api/profile/{id}", JWT(GetProfile)) + mux.HandleFunc("POST /api/profile", JWT(CreateProfile)) + mux.HandleFunc("PUT /api/profile", JWT(UpdateProfile)) + mux.HandleFunc("DELETE /api/profile/{id}", JWT(DeleteProfile)) - mux.HandleFunc("GET /api/providers", JWT(GetProviders)) - mux.HandleFunc("PUT /api/providers/{name}", JWT(UpdateProvider)) - mux.HandleFunc("DELETE /api/providers/{name}", JWT(DeleteProvider)) + mux.HandleFunc("GET /api/provider", JWT(GetProviders)) + mux.HandleFunc("GET /api/provider/{id}", JWT(GetProvider)) + mux.HandleFunc("POST /api/provider", JWT(CreateProvider)) + mux.HandleFunc("PUT /api/provider", JWT(UpdateProvider)) + mux.HandleFunc("DELETE /api/provider/{id}", JWT(DeleteProvider)) - mux.HandleFunc("PUT /api/routers/{profile}/{router}", JWT(UpdateRouter)) - mux.HandleFunc("DELETE /api/routers/{profile}/{router}", JWT(DeleteRouter)) + mux.HandleFunc("GET /api/config/{id}", JWT(GetConfig)) + mux.HandleFunc("PUT /api/config/{id}", JWT(UpdateConfig)) - mux.HandleFunc("PUT /api/services/{profile}/{service}", JWT(UpdateService)) - mux.HandleFunc("DELETE /api/services/{profile}/{service}", JWT(DeleteService)) - - mux.HandleFunc("PUT /api/middlewares/{profile}/{middleware}", JWT(UpdateMiddleware)) - mux.HandleFunc("DELETE /api/middlewares/{profile}/{middleware}", JWT(DeleteMiddleware)) - - mux.HandleFunc("GET /api/{name}", GetConfig) + mux.HandleFunc("GET /api/{id}", GetTraefikConfig) staticContent, err := fs.Sub(web.StaticFS, "build") if err != nil { diff --git a/internal/config/flags.go b/internal/config/flags.go new file mode 100644 index 0000000..1509e3a --- /dev/null +++ b/internal/config/flags.go @@ -0,0 +1,78 @@ +package config + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + + "github.com/MizuchiLabs/mantrae/internal/db" + "github.com/MizuchiLabs/mantrae/pkg/util" +) + +type Flags struct { + Version bool + Port int + URL string + Username string + Password string +} + +func ParseFlags() *Flags { + var flags Flags + + flag.BoolVar(&flags.Version, "version", false, "Print version and exit") + flag.IntVar(&flags.Port, "port", 3000, "Port to listen on") + flag.StringVar( + &flags.URL, + "url", + "", + "Specify the URL of the Traefik instance (e.g. http://localhost:8080)", + ) + flag.StringVar(&flags.Username, "username", "", "Specify the username for the Traefik instance") + flag.StringVar(&flags.Password, "password", "", "Specify the password for the Traefik instance") + + flag.Parse() + + if flags.Version { + fmt.Println(util.Version) + os.Exit(0) + } + + if flags.URL != "" { + SetDefaultProfile(flags.URL, flags.Username, flags.Password) + } + + return &flags +} + +func SetDefaultProfile(url, username, password string) { + profile, err := db.Query.GetProfileByName(context.Background(), "default") + if err != nil { + _, err := db.Query.CreateProfile(context.Background(), db.CreateProfileParams{ + Name: "default", + Url: url, + Username: &username, + Password: &password, + Tls: false, + }) + if err != nil { + slog.Error("Failed to create default profile", "error", err) + } + slog.Info("Generated default profile", "url", url) + return + } + if profile.Url != url || profile.Username != &username || profile.Password != &password { + if _, err := db.Query.UpdateProfile(context.Background(), db.UpdateProfileParams{ + ID: profile.ID, + Name: "default", + Url: url, + Username: &username, + Password: &password, + Tls: false, + }); err != nil { + slog.Error("Failed to update default profile", "error", err) + } + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..b8b793b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,368 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package db + +import ( + "context" + "database/sql" + "fmt" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +func Prepare(ctx context.Context, db DBTX) (*Queries, error) { + q := Queries{db: db} + var err error + if q.createConfigStmt, err = db.PrepareContext(ctx, createConfig); err != nil { + return nil, fmt.Errorf("error preparing query CreateConfig: %w", err) + } + if q.createCredentialStmt, err = db.PrepareContext(ctx, createCredential); err != nil { + return nil, fmt.Errorf("error preparing query CreateCredential: %w", err) + } + if q.createProfileStmt, err = db.PrepareContext(ctx, createProfile); err != nil { + return nil, fmt.Errorf("error preparing query CreateProfile: %w", err) + } + if q.createProviderStmt, err = db.PrepareContext(ctx, createProvider); err != nil { + return nil, fmt.Errorf("error preparing query CreateProvider: %w", err) + } + if q.deleteConfigByProfileIDStmt, err = db.PrepareContext(ctx, deleteConfigByProfileID); err != nil { + return nil, fmt.Errorf("error preparing query DeleteConfigByProfileID: %w", err) + } + if q.deleteConfigByProfileNameStmt, err = db.PrepareContext(ctx, deleteConfigByProfileName); err != nil { + return nil, fmt.Errorf("error preparing query DeleteConfigByProfileName: %w", err) + } + if q.deleteCredentialByIDStmt, err = db.PrepareContext(ctx, deleteCredentialByID); err != nil { + return nil, fmt.Errorf("error preparing query DeleteCredentialByID: %w", err) + } + if q.deleteCredentialByUsernameStmt, err = db.PrepareContext(ctx, deleteCredentialByUsername); err != nil { + return nil, fmt.Errorf("error preparing query DeleteCredentialByUsername: %w", err) + } + if q.deleteProfileByIDStmt, err = db.PrepareContext(ctx, deleteProfileByID); err != nil { + return nil, fmt.Errorf("error preparing query DeleteProfileByID: %w", err) + } + if q.deleteProfileByNameStmt, err = db.PrepareContext(ctx, deleteProfileByName); err != nil { + return nil, fmt.Errorf("error preparing query DeleteProfileByName: %w", err) + } + if q.deleteProviderByIDStmt, err = db.PrepareContext(ctx, deleteProviderByID); err != nil { + return nil, fmt.Errorf("error preparing query DeleteProviderByID: %w", err) + } + if q.deleteProviderByNameStmt, err = db.PrepareContext(ctx, deleteProviderByName); err != nil { + return nil, fmt.Errorf("error preparing query DeleteProviderByName: %w", err) + } + if q.getConfigByProfileIDStmt, err = db.PrepareContext(ctx, getConfigByProfileID); err != nil { + return nil, fmt.Errorf("error preparing query GetConfigByProfileID: %w", err) + } + if q.getConfigByProfileNameStmt, err = db.PrepareContext(ctx, getConfigByProfileName); err != nil { + return nil, fmt.Errorf("error preparing query GetConfigByProfileName: %w", err) + } + if q.getCredentialByIDStmt, err = db.PrepareContext(ctx, getCredentialByID); err != nil { + return nil, fmt.Errorf("error preparing query GetCredentialByID: %w", err) + } + if q.getCredentialByUsernameStmt, err = db.PrepareContext(ctx, getCredentialByUsername); err != nil { + return nil, fmt.Errorf("error preparing query GetCredentialByUsername: %w", err) + } + if q.getProfileByIDStmt, err = db.PrepareContext(ctx, getProfileByID); err != nil { + return nil, fmt.Errorf("error preparing query GetProfileByID: %w", err) + } + if q.getProfileByNameStmt, err = db.PrepareContext(ctx, getProfileByName); err != nil { + return nil, fmt.Errorf("error preparing query GetProfileByName: %w", err) + } + if q.getProviderByIDStmt, err = db.PrepareContext(ctx, getProviderByID); err != nil { + return nil, fmt.Errorf("error preparing query GetProviderByID: %w", err) + } + if q.getProviderByNameStmt, err = db.PrepareContext(ctx, getProviderByName); err != nil { + return nil, fmt.Errorf("error preparing query GetProviderByName: %w", err) + } + if q.listConfigsStmt, err = db.PrepareContext(ctx, listConfigs); err != nil { + return nil, fmt.Errorf("error preparing query ListConfigs: %w", err) + } + if q.listCredentialsStmt, err = db.PrepareContext(ctx, listCredentials); err != nil { + return nil, fmt.Errorf("error preparing query ListCredentials: %w", err) + } + if q.listProfilesStmt, err = db.PrepareContext(ctx, listProfiles); err != nil { + return nil, fmt.Errorf("error preparing query ListProfiles: %w", err) + } + if q.listProvidersStmt, err = db.PrepareContext(ctx, listProviders); err != nil { + return nil, fmt.Errorf("error preparing query ListProviders: %w", err) + } + if q.updateConfigStmt, err = db.PrepareContext(ctx, updateConfig); err != nil { + return nil, fmt.Errorf("error preparing query UpdateConfig: %w", err) + } + if q.updateCredentialStmt, err = db.PrepareContext(ctx, updateCredential); err != nil { + return nil, fmt.Errorf("error preparing query UpdateCredential: %w", err) + } + if q.updateProfileStmt, err = db.PrepareContext(ctx, updateProfile); err != nil { + return nil, fmt.Errorf("error preparing query UpdateProfile: %w", err) + } + if q.updateProviderStmt, err = db.PrepareContext(ctx, updateProvider); err != nil { + return nil, fmt.Errorf("error preparing query UpdateProvider: %w", err) + } + if q.validateAuthStmt, err = db.PrepareContext(ctx, validateAuth); err != nil { + return nil, fmt.Errorf("error preparing query ValidateAuth: %w", err) + } + return &q, nil +} + +func (q *Queries) Close() error { + var err error + if q.createConfigStmt != nil { + if cerr := q.createConfigStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createConfigStmt: %w", cerr) + } + } + if q.createCredentialStmt != nil { + if cerr := q.createCredentialStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createCredentialStmt: %w", cerr) + } + } + if q.createProfileStmt != nil { + if cerr := q.createProfileStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createProfileStmt: %w", cerr) + } + } + if q.createProviderStmt != nil { + if cerr := q.createProviderStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createProviderStmt: %w", cerr) + } + } + if q.deleteConfigByProfileIDStmt != nil { + if cerr := q.deleteConfigByProfileIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteConfigByProfileIDStmt: %w", cerr) + } + } + if q.deleteConfigByProfileNameStmt != nil { + if cerr := q.deleteConfigByProfileNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteConfigByProfileNameStmt: %w", cerr) + } + } + if q.deleteCredentialByIDStmt != nil { + if cerr := q.deleteCredentialByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteCredentialByIDStmt: %w", cerr) + } + } + if q.deleteCredentialByUsernameStmt != nil { + if cerr := q.deleteCredentialByUsernameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteCredentialByUsernameStmt: %w", cerr) + } + } + if q.deleteProfileByIDStmt != nil { + if cerr := q.deleteProfileByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteProfileByIDStmt: %w", cerr) + } + } + if q.deleteProfileByNameStmt != nil { + if cerr := q.deleteProfileByNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteProfileByNameStmt: %w", cerr) + } + } + if q.deleteProviderByIDStmt != nil { + if cerr := q.deleteProviderByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteProviderByIDStmt: %w", cerr) + } + } + if q.deleteProviderByNameStmt != nil { + if cerr := q.deleteProviderByNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteProviderByNameStmt: %w", cerr) + } + } + if q.getConfigByProfileIDStmt != nil { + if cerr := q.getConfigByProfileIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getConfigByProfileIDStmt: %w", cerr) + } + } + if q.getConfigByProfileNameStmt != nil { + if cerr := q.getConfigByProfileNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getConfigByProfileNameStmt: %w", cerr) + } + } + if q.getCredentialByIDStmt != nil { + if cerr := q.getCredentialByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getCredentialByIDStmt: %w", cerr) + } + } + if q.getCredentialByUsernameStmt != nil { + if cerr := q.getCredentialByUsernameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getCredentialByUsernameStmt: %w", cerr) + } + } + if q.getProfileByIDStmt != nil { + if cerr := q.getProfileByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getProfileByIDStmt: %w", cerr) + } + } + if q.getProfileByNameStmt != nil { + if cerr := q.getProfileByNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getProfileByNameStmt: %w", cerr) + } + } + if q.getProviderByIDStmt != nil { + if cerr := q.getProviderByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getProviderByIDStmt: %w", cerr) + } + } + if q.getProviderByNameStmt != nil { + if cerr := q.getProviderByNameStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getProviderByNameStmt: %w", cerr) + } + } + if q.listConfigsStmt != nil { + if cerr := q.listConfigsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listConfigsStmt: %w", cerr) + } + } + if q.listCredentialsStmt != nil { + if cerr := q.listCredentialsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listCredentialsStmt: %w", cerr) + } + } + if q.listProfilesStmt != nil { + if cerr := q.listProfilesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listProfilesStmt: %w", cerr) + } + } + if q.listProvidersStmt != nil { + if cerr := q.listProvidersStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listProvidersStmt: %w", cerr) + } + } + if q.updateConfigStmt != nil { + if cerr := q.updateConfigStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateConfigStmt: %w", cerr) + } + } + if q.updateCredentialStmt != nil { + if cerr := q.updateCredentialStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateCredentialStmt: %w", cerr) + } + } + if q.updateProfileStmt != nil { + if cerr := q.updateProfileStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateProfileStmt: %w", cerr) + } + } + if q.updateProviderStmt != nil { + if cerr := q.updateProviderStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateProviderStmt: %w", cerr) + } + } + if q.validateAuthStmt != nil { + if cerr := q.validateAuthStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing validateAuthStmt: %w", cerr) + } + } + return err +} + +func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) + case stmt != nil: + return stmt.ExecContext(ctx, args...) + default: + return q.db.ExecContext(ctx, query, args...) + } +} + +func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) + case stmt != nil: + return stmt.QueryContext(ctx, args...) + default: + return q.db.QueryContext(ctx, query, args...) + } +} + +func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { + switch { + case stmt != nil && q.tx != nil: + return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) + case stmt != nil: + return stmt.QueryRowContext(ctx, args...) + default: + return q.db.QueryRowContext(ctx, query, args...) + } +} + +type Queries struct { + db DBTX + tx *sql.Tx + createConfigStmt *sql.Stmt + createCredentialStmt *sql.Stmt + createProfileStmt *sql.Stmt + createProviderStmt *sql.Stmt + deleteConfigByProfileIDStmt *sql.Stmt + deleteConfigByProfileNameStmt *sql.Stmt + deleteCredentialByIDStmt *sql.Stmt + deleteCredentialByUsernameStmt *sql.Stmt + deleteProfileByIDStmt *sql.Stmt + deleteProfileByNameStmt *sql.Stmt + deleteProviderByIDStmt *sql.Stmt + deleteProviderByNameStmt *sql.Stmt + getConfigByProfileIDStmt *sql.Stmt + getConfigByProfileNameStmt *sql.Stmt + getCredentialByIDStmt *sql.Stmt + getCredentialByUsernameStmt *sql.Stmt + getProfileByIDStmt *sql.Stmt + getProfileByNameStmt *sql.Stmt + getProviderByIDStmt *sql.Stmt + getProviderByNameStmt *sql.Stmt + listConfigsStmt *sql.Stmt + listCredentialsStmt *sql.Stmt + listProfilesStmt *sql.Stmt + listProvidersStmt *sql.Stmt + updateConfigStmt *sql.Stmt + updateCredentialStmt *sql.Stmt + updateProfileStmt *sql.Stmt + updateProviderStmt *sql.Stmt + validateAuthStmt *sql.Stmt +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + tx: tx, + createConfigStmt: q.createConfigStmt, + createCredentialStmt: q.createCredentialStmt, + createProfileStmt: q.createProfileStmt, + createProviderStmt: q.createProviderStmt, + deleteConfigByProfileIDStmt: q.deleteConfigByProfileIDStmt, + deleteConfigByProfileNameStmt: q.deleteConfigByProfileNameStmt, + deleteCredentialByIDStmt: q.deleteCredentialByIDStmt, + deleteCredentialByUsernameStmt: q.deleteCredentialByUsernameStmt, + deleteProfileByIDStmt: q.deleteProfileByIDStmt, + deleteProfileByNameStmt: q.deleteProfileByNameStmt, + deleteProviderByIDStmt: q.deleteProviderByIDStmt, + deleteProviderByNameStmt: q.deleteProviderByNameStmt, + getConfigByProfileIDStmt: q.getConfigByProfileIDStmt, + getConfigByProfileNameStmt: q.getConfigByProfileNameStmt, + getCredentialByIDStmt: q.getCredentialByIDStmt, + getCredentialByUsernameStmt: q.getCredentialByUsernameStmt, + getProfileByIDStmt: q.getProfileByIDStmt, + getProfileByNameStmt: q.getProfileByNameStmt, + getProviderByIDStmt: q.getProviderByIDStmt, + getProviderByNameStmt: q.getProviderByNameStmt, + listConfigsStmt: q.listConfigsStmt, + listCredentialsStmt: q.listCredentialsStmt, + listProfilesStmt: q.listProfilesStmt, + listProvidersStmt: q.listProvidersStmt, + updateConfigStmt: q.updateConfigStmt, + updateCredentialStmt: q.updateCredentialStmt, + updateProfileStmt: q.updateProfileStmt, + updateProviderStmt: q.updateProviderStmt, + validateAuthStmt: q.validateAuthStmt, + } +} diff --git a/internal/db/init.go b/internal/db/init.go new file mode 100644 index 0000000..bd843bd --- /dev/null +++ b/internal/db/init.go @@ -0,0 +1,43 @@ +package db + +import ( + "context" + "database/sql" + _ "embed" + "fmt" + + _ "github.com/mattn/go-sqlite3" +) + +//go:embed schema.sql +var ddl string + +var Query *Queries + +func InitDB() (*sql.DB, error) { + ctx := context.Background() + + db, err := sql.Open("sqlite3", "file:mantrae.db?mode=rwc&_journal=WAL&_fk=1&_sync=NORMAL") + if err != nil { + db.Close() + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Check if the database is empty + var count int + err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").Scan(&count) + if err != nil { + db.Close() + return nil, fmt.Errorf("failed to check database: %w", err) + } + + if count == 0 { + if _, err := db.ExecContext(ctx, ddl); err != nil { + db.Close() + return nil, fmt.Errorf("failed to execute schema: %w", err) + } + } + + Query = New(db) + return db, nil +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..9006976 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,38 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package db + +type Config struct { + ProfileID int64 `json:"profile_id"` + Entrypoints interface{} `json:"entrypoints"` + Routers interface{} `json:"routers"` + Services interface{} `json:"services"` + Middlewares interface{} `json:"middlewares"` + Version *string `json:"version"` +} + +type Credential struct { + ID int64 `json:"id"` + Username string `json:"username"` + Password string `json:"password"` +} + +type Profile struct { + ID int64 `json:"id"` + Name string `json:"name"` + Url string `json:"url"` + Username *string `json:"username"` + Password *string `json:"password"` + Tls bool `json:"tls"` +} + +type Provider struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + ExternalIp string `json:"external_ip"` + ApiKey string `json:"api_key"` + ApiUrl *string `json:"api_url"` +} diff --git a/internal/db/querier.go b/internal/db/querier.go new file mode 100644 index 0000000..26fa725 --- /dev/null +++ b/internal/db/querier.go @@ -0,0 +1,43 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package db + +import ( + "context" +) + +type Querier interface { + CreateConfig(ctx context.Context, arg CreateConfigParams) (Config, error) + CreateCredential(ctx context.Context, arg CreateCredentialParams) error + CreateProfile(ctx context.Context, arg CreateProfileParams) (Profile, error) + CreateProvider(ctx context.Context, arg CreateProviderParams) (Provider, error) + DeleteConfigByProfileID(ctx context.Context, profileID int64) error + DeleteConfigByProfileName(ctx context.Context, name string) error + DeleteCredentialByID(ctx context.Context, id int64) error + DeleteCredentialByUsername(ctx context.Context, username string) error + DeleteProfileByID(ctx context.Context, id int64) error + DeleteProfileByName(ctx context.Context, name string) error + DeleteProviderByID(ctx context.Context, id int64) error + DeleteProviderByName(ctx context.Context, name string) error + GetConfigByProfileID(ctx context.Context, profileID int64) (Config, error) + GetConfigByProfileName(ctx context.Context, name string) (Config, error) + GetCredentialByID(ctx context.Context, id int64) (Credential, error) + GetCredentialByUsername(ctx context.Context, username string) (Credential, error) + GetProfileByID(ctx context.Context, id int64) (Profile, error) + GetProfileByName(ctx context.Context, name string) (Profile, error) + GetProviderByID(ctx context.Context, id int64) (Provider, error) + GetProviderByName(ctx context.Context, name string) (Provider, error) + ListConfigs(ctx context.Context) ([]Config, error) + ListCredentials(ctx context.Context) ([]Credential, error) + ListProfiles(ctx context.Context) ([]Profile, error) + ListProviders(ctx context.Context) ([]Provider, error) + UpdateConfig(ctx context.Context, arg UpdateConfigParams) (Config, error) + UpdateCredential(ctx context.Context, arg UpdateCredentialParams) error + UpdateProfile(ctx context.Context, arg UpdateProfileParams) (Profile, error) + UpdateProvider(ctx context.Context, arg UpdateProviderParams) (Provider, error) + ValidateAuth(ctx context.Context, arg ValidateAuthParams) (ValidateAuthRow, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/db/query.sql b/internal/db/query.sql new file mode 100644 index 0000000..dafa865 --- /dev/null +++ b/internal/db/query.sql @@ -0,0 +1,239 @@ +-- name: GetProfileByID :one +SELECT + * +FROM + profiles +WHERE + id = ? +LIMIT + 1; + +-- name: GetProfileByName :one +SELECT + * +FROM + profiles +WHERE + name = ? +LIMIT + 1; + +-- name: ListProfiles :many +SELECT + * +FROM + profiles; + +-- name: CreateProfile :one +INSERT INTO + profiles (name, url, username, password, tls) +VALUES + (?, ?, ?, ?, ?) RETURNING *; + +-- name: UpdateProfile :one +UPDATE profiles +SET + name = ?, + url = ?, + username = ?, + password = ?, + tls = ? +WHERE + id = ? RETURNING *; + +-- name: DeleteProfileByID :exec +DELETE FROM profiles +WHERE + id = ?; + +-- name: DeleteProfileByName :exec +DELETE FROM profiles +WHERE + name = ?; + +-- name: GetConfigByProfileID :one +SELECT + * +FROM + config +WHERE + profile_id = ? +LIMIT + 1; + +-- name: GetConfigByProfileName :one +SELECT + * +FROM + config +WHERE + profile_id = ( + SELECT + id + FROM + profiles + WHERE + name = ? + ) +LIMIT + 1; + +-- name: ListConfigs :many +SELECT + * +FROM + config; + +-- name: CreateConfig :one +INSERT INTO + config ( + profile_id, + entrypoints, + routers, + services, + middlewares, + version + ) +VALUES + (?, ?, ?, ?, ?, ?) RETURNING *; + +-- name: UpdateConfig :one +UPDATE config +SET + entrypoints = ?, + routers = ?, + services = ?, + middlewares = ?, + version = ? +WHERE + profile_id = ? RETURNING *; + +-- name: DeleteConfigByProfileID :exec +DELETE FROM config +WHERE + profile_id = ?; + +-- name: DeleteConfigByProfileName :exec +DELETE FROM config +WHERE + profile_id = ( + SELECT + id + FROM + profiles + WHERE + name = ? + ); + +-- name: GetProviderByID :one +SELECT + * +FROM + providers +WHERE + id = ? +LIMIT + 1; + +-- name: GetProviderByName :one +SELECT + * +FROM + providers +WHERE + name = ? +LIMIT + 1; + +-- name: ListProviders :many +SELECT + * +FROM + providers; + +-- name: CreateProvider :one +INSERT INTO + providers (name, type, external_ip, api_key, api_url) +VALUES + (?, ?, ?, ?, ?) RETURNING *; + +-- name: UpdateProvider :one +UPDATE providers +SET + name = ?, + type = ?, + external_ip = ?, + api_key = ?, + api_url = ? +WHERE + id = ? RETURNING *; + +-- name: DeleteProviderByID :exec +DELETE FROM providers +WHERE + id = ?; + +-- name: DeleteProviderByName :exec +DELETE FROM providers +WHERE + name = ?; + +-- name: GetCredentialByID :one +SELECT + * +FROM + credentials +WHERE + id = ? +LIMIT + 1; + +-- name: GetCredentialByUsername :one +SELECT + * +FROM + credentials +WHERE + username = ? +LIMIT + 1; + +-- name: ListCredentials :many +SELECT + * +FROM + credentials; + +-- name: CreateCredential :exec +INSERT INTO + credentials (username, password) +VALUES + (?, ?); + +-- name: UpdateCredential :exec +UPDATE credentials +SET + username = ?, + password = ? +WHERE + id = ?; + +-- name: DeleteCredentialByID :exec +DELETE FROM credentials +WHERE + id = ?; + +-- name: DeleteCredentialByUsername :exec +DELETE FROM credentials +WHERE + username = ?; + +-- name: ValidateAuth :one +SELECT + id, + username +FROM + credentials +WHERE + username = ? + AND password = ?; diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go new file mode 100644 index 0000000..bd7450a --- /dev/null +++ b/internal/db/query.sql.go @@ -0,0 +1,744 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: query.sql + +package db + +import ( + "context" +) + +const createConfig = `-- name: CreateConfig :one +INSERT INTO + config ( + profile_id, + entrypoints, + routers, + services, + middlewares, + version + ) +VALUES + (?, ?, ?, ?, ?, ?) RETURNING profile_id, entrypoints, routers, services, middlewares, version +` + +type CreateConfigParams struct { + ProfileID int64 `json:"profile_id"` + Entrypoints interface{} `json:"entrypoints"` + Routers interface{} `json:"routers"` + Services interface{} `json:"services"` + Middlewares interface{} `json:"middlewares"` + Version *string `json:"version"` +} + +func (q *Queries) CreateConfig(ctx context.Context, arg CreateConfigParams) (Config, error) { + row := q.queryRow(ctx, q.createConfigStmt, createConfig, + arg.ProfileID, + arg.Entrypoints, + arg.Routers, + arg.Services, + arg.Middlewares, + arg.Version, + ) + var i Config + err := row.Scan( + &i.ProfileID, + &i.Entrypoints, + &i.Routers, + &i.Services, + &i.Middlewares, + &i.Version, + ) + return i, err +} + +const createCredential = `-- name: CreateCredential :exec +INSERT INTO + credentials (username, password) +VALUES + (?, ?) +` + +type CreateCredentialParams struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (q *Queries) CreateCredential(ctx context.Context, arg CreateCredentialParams) error { + _, err := q.exec(ctx, q.createCredentialStmt, createCredential, arg.Username, arg.Password) + return err +} + +const createProfile = `-- name: CreateProfile :one +INSERT INTO + profiles (name, url, username, password, tls) +VALUES + (?, ?, ?, ?, ?) RETURNING id, name, url, username, password, tls +` + +type CreateProfileParams struct { + Name string `json:"name"` + Url string `json:"url"` + Username *string `json:"username"` + Password *string `json:"password"` + Tls bool `json:"tls"` +} + +func (q *Queries) CreateProfile(ctx context.Context, arg CreateProfileParams) (Profile, error) { + row := q.queryRow(ctx, q.createProfileStmt, createProfile, + arg.Name, + arg.Url, + arg.Username, + arg.Password, + arg.Tls, + ) + var i Profile + err := row.Scan( + &i.ID, + &i.Name, + &i.Url, + &i.Username, + &i.Password, + &i.Tls, + ) + return i, err +} + +const createProvider = `-- name: CreateProvider :one +INSERT INTO + providers (name, type, external_ip, api_key, api_url) +VALUES + (?, ?, ?, ?, ?) RETURNING id, name, type, external_ip, api_key, api_url +` + +type CreateProviderParams struct { + Name string `json:"name"` + Type string `json:"type"` + ExternalIp string `json:"external_ip"` + ApiKey string `json:"api_key"` + ApiUrl *string `json:"api_url"` +} + +func (q *Queries) CreateProvider(ctx context.Context, arg CreateProviderParams) (Provider, error) { + row := q.queryRow(ctx, q.createProviderStmt, createProvider, + arg.Name, + arg.Type, + arg.ExternalIp, + arg.ApiKey, + arg.ApiUrl, + ) + var i Provider + err := row.Scan( + &i.ID, + &i.Name, + &i.Type, + &i.ExternalIp, + &i.ApiKey, + &i.ApiUrl, + ) + return i, err +} + +const deleteConfigByProfileID = `-- name: DeleteConfigByProfileID :exec +DELETE FROM config +WHERE + profile_id = ? +` + +func (q *Queries) DeleteConfigByProfileID(ctx context.Context, profileID int64) error { + _, err := q.exec(ctx, q.deleteConfigByProfileIDStmt, deleteConfigByProfileID, profileID) + return err +} + +const deleteConfigByProfileName = `-- name: DeleteConfigByProfileName :exec +DELETE FROM config +WHERE + profile_id = ( + SELECT + id + FROM + profiles + WHERE + name = ? + ) +` + +func (q *Queries) DeleteConfigByProfileName(ctx context.Context, name string) error { + _, err := q.exec(ctx, q.deleteConfigByProfileNameStmt, deleteConfigByProfileName, name) + return err +} + +const deleteCredentialByID = `-- name: DeleteCredentialByID :exec +DELETE FROM credentials +WHERE + id = ? +` + +func (q *Queries) DeleteCredentialByID(ctx context.Context, id int64) error { + _, err := q.exec(ctx, q.deleteCredentialByIDStmt, deleteCredentialByID, id) + return err +} + +const deleteCredentialByUsername = `-- name: DeleteCredentialByUsername :exec +DELETE FROM credentials +WHERE + username = ? +` + +func (q *Queries) DeleteCredentialByUsername(ctx context.Context, username string) error { + _, err := q.exec(ctx, q.deleteCredentialByUsernameStmt, deleteCredentialByUsername, username) + return err +} + +const deleteProfileByID = `-- name: DeleteProfileByID :exec +DELETE FROM profiles +WHERE + id = ? +` + +func (q *Queries) DeleteProfileByID(ctx context.Context, id int64) error { + _, err := q.exec(ctx, q.deleteProfileByIDStmt, deleteProfileByID, id) + return err +} + +const deleteProfileByName = `-- name: DeleteProfileByName :exec +DELETE FROM profiles +WHERE + name = ? +` + +func (q *Queries) DeleteProfileByName(ctx context.Context, name string) error { + _, err := q.exec(ctx, q.deleteProfileByNameStmt, deleteProfileByName, name) + return err +} + +const deleteProviderByID = `-- name: DeleteProviderByID :exec +DELETE FROM providers +WHERE + id = ? +` + +func (q *Queries) DeleteProviderByID(ctx context.Context, id int64) error { + _, err := q.exec(ctx, q.deleteProviderByIDStmt, deleteProviderByID, id) + return err +} + +const deleteProviderByName = `-- name: DeleteProviderByName :exec +DELETE FROM providers +WHERE + name = ? +` + +func (q *Queries) DeleteProviderByName(ctx context.Context, name string) error { + _, err := q.exec(ctx, q.deleteProviderByNameStmt, deleteProviderByName, name) + return err +} + +const getConfigByProfileID = `-- name: GetConfigByProfileID :one +SELECT + profile_id, entrypoints, routers, services, middlewares, version +FROM + config +WHERE + profile_id = ? +LIMIT + 1 +` + +func (q *Queries) GetConfigByProfileID(ctx context.Context, profileID int64) (Config, error) { + row := q.queryRow(ctx, q.getConfigByProfileIDStmt, getConfigByProfileID, profileID) + var i Config + err := row.Scan( + &i.ProfileID, + &i.Entrypoints, + &i.Routers, + &i.Services, + &i.Middlewares, + &i.Version, + ) + return i, err +} + +const getConfigByProfileName = `-- name: GetConfigByProfileName :one +SELECT + profile_id, entrypoints, routers, services, middlewares, version +FROM + config +WHERE + profile_id = ( + SELECT + id + FROM + profiles + WHERE + name = ? + ) +LIMIT + 1 +` + +func (q *Queries) GetConfigByProfileName(ctx context.Context, name string) (Config, error) { + row := q.queryRow(ctx, q.getConfigByProfileNameStmt, getConfigByProfileName, name) + var i Config + err := row.Scan( + &i.ProfileID, + &i.Entrypoints, + &i.Routers, + &i.Services, + &i.Middlewares, + &i.Version, + ) + return i, err +} + +const getCredentialByID = `-- name: GetCredentialByID :one +SELECT + id, username, password +FROM + credentials +WHERE + id = ? +LIMIT + 1 +` + +func (q *Queries) GetCredentialByID(ctx context.Context, id int64) (Credential, error) { + row := q.queryRow(ctx, q.getCredentialByIDStmt, getCredentialByID, id) + var i Credential + err := row.Scan(&i.ID, &i.Username, &i.Password) + return i, err +} + +const getCredentialByUsername = `-- name: GetCredentialByUsername :one +SELECT + id, username, password +FROM + credentials +WHERE + username = ? +LIMIT + 1 +` + +func (q *Queries) GetCredentialByUsername(ctx context.Context, username string) (Credential, error) { + row := q.queryRow(ctx, q.getCredentialByUsernameStmt, getCredentialByUsername, username) + var i Credential + err := row.Scan(&i.ID, &i.Username, &i.Password) + return i, err +} + +const getProfileByID = `-- name: GetProfileByID :one +SELECT + id, name, url, username, password, tls +FROM + profiles +WHERE + id = ? +LIMIT + 1 +` + +func (q *Queries) GetProfileByID(ctx context.Context, id int64) (Profile, error) { + row := q.queryRow(ctx, q.getProfileByIDStmt, getProfileByID, id) + var i Profile + err := row.Scan( + &i.ID, + &i.Name, + &i.Url, + &i.Username, + &i.Password, + &i.Tls, + ) + return i, err +} + +const getProfileByName = `-- name: GetProfileByName :one +SELECT + id, name, url, username, password, tls +FROM + profiles +WHERE + name = ? +LIMIT + 1 +` + +func (q *Queries) GetProfileByName(ctx context.Context, name string) (Profile, error) { + row := q.queryRow(ctx, q.getProfileByNameStmt, getProfileByName, name) + var i Profile + err := row.Scan( + &i.ID, + &i.Name, + &i.Url, + &i.Username, + &i.Password, + &i.Tls, + ) + return i, err +} + +const getProviderByID = `-- name: GetProviderByID :one +SELECT + id, name, type, external_ip, api_key, api_url +FROM + providers +WHERE + id = ? +LIMIT + 1 +` + +func (q *Queries) GetProviderByID(ctx context.Context, id int64) (Provider, error) { + row := q.queryRow(ctx, q.getProviderByIDStmt, getProviderByID, id) + var i Provider + err := row.Scan( + &i.ID, + &i.Name, + &i.Type, + &i.ExternalIp, + &i.ApiKey, + &i.ApiUrl, + ) + return i, err +} + +const getProviderByName = `-- name: GetProviderByName :one +SELECT + id, name, type, external_ip, api_key, api_url +FROM + providers +WHERE + name = ? +LIMIT + 1 +` + +func (q *Queries) GetProviderByName(ctx context.Context, name string) (Provider, error) { + row := q.queryRow(ctx, q.getProviderByNameStmt, getProviderByName, name) + var i Provider + err := row.Scan( + &i.ID, + &i.Name, + &i.Type, + &i.ExternalIp, + &i.ApiKey, + &i.ApiUrl, + ) + return i, err +} + +const listConfigs = `-- name: ListConfigs :many +SELECT + profile_id, entrypoints, routers, services, middlewares, version +FROM + config +` + +func (q *Queries) ListConfigs(ctx context.Context) ([]Config, error) { + rows, err := q.query(ctx, q.listConfigsStmt, listConfigs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Config + for rows.Next() { + var i Config + if err := rows.Scan( + &i.ProfileID, + &i.Entrypoints, + &i.Routers, + &i.Services, + &i.Middlewares, + &i.Version, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listCredentials = `-- name: ListCredentials :many +SELECT + id, username, password +FROM + credentials +` + +func (q *Queries) ListCredentials(ctx context.Context) ([]Credential, error) { + rows, err := q.query(ctx, q.listCredentialsStmt, listCredentials) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Credential + for rows.Next() { + var i Credential + if err := rows.Scan(&i.ID, &i.Username, &i.Password); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProfiles = `-- name: ListProfiles :many +SELECT + id, name, url, username, password, tls +FROM + profiles +` + +func (q *Queries) ListProfiles(ctx context.Context) ([]Profile, error) { + rows, err := q.query(ctx, q.listProfilesStmt, listProfiles) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Profile + for rows.Next() { + var i Profile + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Url, + &i.Username, + &i.Password, + &i.Tls, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProviders = `-- name: ListProviders :many +SELECT + id, name, type, external_ip, api_key, api_url +FROM + providers +` + +func (q *Queries) ListProviders(ctx context.Context) ([]Provider, error) { + rows, err := q.query(ctx, q.listProvidersStmt, listProviders) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Provider + for rows.Next() { + var i Provider + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Type, + &i.ExternalIp, + &i.ApiKey, + &i.ApiUrl, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateConfig = `-- name: UpdateConfig :one +UPDATE config +SET + entrypoints = ?, + routers = ?, + services = ?, + middlewares = ?, + version = ? +WHERE + profile_id = ? RETURNING profile_id, entrypoints, routers, services, middlewares, version +` + +type UpdateConfigParams struct { + Entrypoints interface{} `json:"entrypoints"` + Routers interface{} `json:"routers"` + Services interface{} `json:"services"` + Middlewares interface{} `json:"middlewares"` + Version *string `json:"version"` + ProfileID int64 `json:"profile_id"` +} + +func (q *Queries) UpdateConfig(ctx context.Context, arg UpdateConfigParams) (Config, error) { + row := q.queryRow(ctx, q.updateConfigStmt, updateConfig, + arg.Entrypoints, + arg.Routers, + arg.Services, + arg.Middlewares, + arg.Version, + arg.ProfileID, + ) + var i Config + err := row.Scan( + &i.ProfileID, + &i.Entrypoints, + &i.Routers, + &i.Services, + &i.Middlewares, + &i.Version, + ) + return i, err +} + +const updateCredential = `-- name: UpdateCredential :exec +UPDATE credentials +SET + username = ?, + password = ? +WHERE + id = ? +` + +type UpdateCredentialParams struct { + Username string `json:"username"` + Password string `json:"password"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateCredential(ctx context.Context, arg UpdateCredentialParams) error { + _, err := q.exec(ctx, q.updateCredentialStmt, updateCredential, arg.Username, arg.Password, arg.ID) + return err +} + +const updateProfile = `-- name: UpdateProfile :one +UPDATE profiles +SET + name = ?, + url = ?, + username = ?, + password = ?, + tls = ? +WHERE + id = ? RETURNING id, name, url, username, password, tls +` + +type UpdateProfileParams struct { + Name string `json:"name"` + Url string `json:"url"` + Username *string `json:"username"` + Password *string `json:"password"` + Tls bool `json:"tls"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateProfile(ctx context.Context, arg UpdateProfileParams) (Profile, error) { + row := q.queryRow(ctx, q.updateProfileStmt, updateProfile, + arg.Name, + arg.Url, + arg.Username, + arg.Password, + arg.Tls, + arg.ID, + ) + var i Profile + err := row.Scan( + &i.ID, + &i.Name, + &i.Url, + &i.Username, + &i.Password, + &i.Tls, + ) + return i, err +} + +const updateProvider = `-- name: UpdateProvider :one +UPDATE providers +SET + name = ?, + type = ?, + external_ip = ?, + api_key = ?, + api_url = ? +WHERE + id = ? RETURNING id, name, type, external_ip, api_key, api_url +` + +type UpdateProviderParams struct { + Name string `json:"name"` + Type string `json:"type"` + ExternalIp string `json:"external_ip"` + ApiKey string `json:"api_key"` + ApiUrl *string `json:"api_url"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateProvider(ctx context.Context, arg UpdateProviderParams) (Provider, error) { + row := q.queryRow(ctx, q.updateProviderStmt, updateProvider, + arg.Name, + arg.Type, + arg.ExternalIp, + arg.ApiKey, + arg.ApiUrl, + arg.ID, + ) + var i Provider + err := row.Scan( + &i.ID, + &i.Name, + &i.Type, + &i.ExternalIp, + &i.ApiKey, + &i.ApiUrl, + ) + return i, err +} + +const validateAuth = `-- name: ValidateAuth :one +SELECT + id, + username +FROM + credentials +WHERE + username = ? + AND password = ? +` + +type ValidateAuthParams struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type ValidateAuthRow struct { + ID int64 `json:"id"` + Username string `json:"username"` +} + +func (q *Queries) ValidateAuth(ctx context.Context, arg ValidateAuthParams) (ValidateAuthRow, error) { + row := q.queryRow(ctx, q.validateAuthStmt, validateAuth, arg.Username, arg.Password) + var i ValidateAuthRow + err := row.Scan(&i.ID, &i.Username) + return i, err +} diff --git a/internal/db/schema.sql b/internal/db/schema.sql new file mode 100644 index 0000000..958100d --- /dev/null +++ b/internal/db/schema.sql @@ -0,0 +1,43 @@ +-- db/schema.sql +CREATE TABLE profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL UNIQUE, + url TEXT NOT NULL, + username VARCHAR(100), + password TEXT, + tls BOOLEAN NOT NULL +); + +CREATE TABLE config ( + profile_id INTEGER NOT NULL, + entrypoints JSONB, + routers JSONB, + services JSONB, + middlewares JSONB, + version TEXT, + FOREIGN KEY (profile_id) REFERENCES profiles (id) ON DELETE CASCADE +); + +CREATE TABLE providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL UNIQUE, + type VARCHAR(50) NOT NULL, + external_ip TEXT NOT NULL, + api_key TEXT NOT NULL, + api_url TEXT +); + +CREATE TABLE credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(100) NOT NULL UNIQUE, + password TEXT NOT NULL +); + +-- Trigger to create an empty config when inserting a profile +CREATE TRIGGER add_config AFTER INSERT ON profiles FOR EACH ROW BEGIN +INSERT INTO + config (profile_id) +VALUES + (NEW.id); + +END; diff --git a/main.go b/main.go index 8ef327e..c22243c 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,6 @@ package main import ( "context" - "flag" - "fmt" "log/slog" "net/http" "os" @@ -13,6 +11,8 @@ import ( "time" "github.com/MizuchiLabs/mantrae/internal/api" + "github.com/MizuchiLabs/mantrae/internal/config" + "github.com/MizuchiLabs/mantrae/internal/db" "github.com/MizuchiLabs/mantrae/pkg/traefik" "github.com/MizuchiLabs/mantrae/pkg/util" "github.com/lmittmann/tint" @@ -22,60 +22,30 @@ import ( func init() { logger := slog.New(tint.NewHandler(os.Stdout, nil)) slog.SetDefault(logger) - if err := util.GenerateCreds(); err != nil { - slog.Error("Failed to generate creds", "error", err) - } - var profiles traefik.Profiles - if err := profiles.Load(); err != nil { - slog.Error("Failed to get traefik config", "error", err) - } - go traefik.GetTraefikConfig() } func main() { - version := flag.Bool("version", false, "Print version and exit") - port := flag.Int("port", 3000, "Port to listen on") - url := flag.String( - "url", - "", - "Specify the URL of the Traefik instance (e.g. http://localhost:8080)", - ) - username := flag.String( - "username", - "", - "Specify the username for the Traefik instance", - ) - password := flag.String( - "password", - "", - "Specify the password for the Traefik instance", - ) - flag.Parse() - - if *version { - fmt.Println(util.Version) - os.Exit(0) + db, err := db.InitDB() + if err != nil { + slog.Error("Failed to initialize database", "error", err) + return } + defer db.Close() // Close the database connection when the program exits - if *url != "" { - var profiles traefik.Profiles - if err := profiles.SetDefaultProfile(*url, *username, *password); err != nil { - slog.Error("Failed to add default profile", "error", err) - return - } - } + flags := config.ParseFlags() // Parse command-line flags + util.SetDefaultAdminUser() // Set default admin user // Start the background sync processes go traefik.Sync() // go dns.Sync() srv := &http.Server{ - Addr: ":" + strconv.Itoa(*port), + Addr: ":" + strconv.Itoa(flags.Port), Handler: api.Routes(), ReadHeaderTimeout: 5 * time.Second, } - slog.Info("Listening on port", "port", *port) + slog.Info("Listening on port", "port", flags.Port) go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("ListenAndServe", "error", err) diff --git a/pkg/dns/client.go b/pkg/dns/client.go index e37088d..aba0660 100644 --- a/pkg/dns/client.go +++ b/pkg/dns/client.go @@ -1,11 +1,13 @@ package dns import ( + "context" "fmt" "log/slog" "regexp" "time" + "github.com/MizuchiLabs/mantrae/internal/db" "github.com/MizuchiLabs/mantrae/pkg/traefik" ) @@ -53,10 +55,28 @@ func UpdateDNS() { return } + profiles, err := db.Query.ListProfiles(context.Background()) + if err != nil { + slog.Error("Failed to get profiles", "error", err) + return + } + // Get all local domains := make(map[string]string) - for _, profile := range traefik.ProfileData.Profiles { - for _, router := range profile.Dynamic.Routers { + for _, profile := range profiles { + config, err := db.Query.GetConfigByProfileID(context.Background(), profile.ID) + if err != nil { + slog.Error("Failed to get config", "error", err) + return + } + + data, err := traefik.DecodeConfig(config) + if err != nil { + slog.Error("Failed to decode config", "error", err) + return + } + + for _, router := range data.Routers { if router.Provider == "http" { domain, err := extractDomainFromRule(router.Rule) if err != nil { diff --git a/pkg/traefik/client.go b/pkg/traefik/client.go index 33cfea1..579109d 100644 --- a/pkg/traefik/client.go +++ b/pkg/traefik/client.go @@ -1,6 +1,7 @@ package traefik import ( + "context" "crypto/tls" "encoding/json" "fmt" @@ -10,6 +11,7 @@ import ( "net/http" "time" + "github.com/MizuchiLabs/mantrae/internal/db" "github.com/traefik/genconf/dynamic" ) @@ -107,8 +109,8 @@ func (r UDPRouter) ToRouter() *Router { } } -func getRouters[T Routerable](p Profile, endpoint string) map[string]Router { - body, err := p.fetch(endpoint) +func getRouters[T Routerable](profile db.Profile, endpoint string) map[string]Router { + body, err := fetch(profile, endpoint) if err != nil { slog.Error("Failed to get routers", "error", err) return nil @@ -126,6 +128,9 @@ func getRouters[T Routerable](p Profile, endpoint string) map[string]Router { routers := make(map[string]Router, len(routerables)) for _, r := range routerables { newRouter := r.ToRouter() + if newRouter.Name == "" { + continue + } routers[newRouter.Name] = *newRouter } return routers @@ -202,8 +207,8 @@ func (s UDPService) ToService() *Service { } } -func getServices[T Serviceable](p Profile, endpoint string) map[string]Service { - body, err := p.fetch(endpoint) +func getServices[T Serviceable](profile db.Profile, endpoint string) map[string]Service { + body, err := fetch(profile, endpoint) if err != nil { slog.Error("Failed to get services", "error", err) return nil @@ -219,6 +224,9 @@ func getServices[T Serviceable](p Profile, endpoint string) map[string]Service { services := make(map[string]Service, len(serviceables)) for _, s := range serviceables { newService := s.ToService() + if newService.Name == "" { + continue + } services[newService.Name] = *newService } @@ -305,8 +313,8 @@ func (m TCPMiddleware) ToMiddleware() *Middleware { } } -func getMiddlewares[T Middlewareable](p Profile, endpoint string) map[string]Middleware { - body, err := p.fetch(endpoint) +func getMiddlewares[T Middlewareable](profile db.Profile, endpoint string) map[string]Middleware { + body, err := fetch(profile, endpoint) if err != nil { slog.Error("Failed to get middlewares", "error", err) return nil @@ -322,6 +330,9 @@ func getMiddlewares[T Middlewareable](p Profile, endpoint string) map[string]Mid middlewares := make(map[string]Middleware, len(middlewareables)) for _, m := range middlewareables { newMiddleware := m.ToMiddleware() + if newMiddleware.Name == "" { + continue + } middlewares[newMiddleware.Name] = *newMiddleware } @@ -329,65 +340,76 @@ func getMiddlewares[T Middlewareable](p Profile, endpoint string) map[string]Mid } func GetTraefikConfig() { - for i, profile := range ProfileData.Profiles { - if profile.URL == "" { + profiles, err := db.Query.ListProfiles(context.Background()) + if err != nil { + slog.Error("Failed to get profiles", "error", err) + return + } + + for _, profile := range profiles { + if profile.Url == "" { continue } - d := Dynamic{ - Entrypoints: make([]Entrypoint, 0), - Routers: make(map[string]Router), - Services: make(map[string]Service), - Middlewares: make(map[string]Middleware), + config, err := db.Query.GetConfigByProfileID(context.Background(), profile.ID) + if err != nil { + slog.Error("Failed to get config", "error", err) + return } - // Retrieve routers - d.Routers = merge( + data, err := DecodeConfig(config) + if err != nil { + slog.Error("Failed to decode config", "error", err) + return + } + + // Fetch routers + data.Routers = merge( getRouters[HTTPRouter](profile, HTTPRouterAPI), getRouters[TCPRouter](profile, TCPRouterAPI), getRouters[UDPRouter](profile, UDPRouterAPI), filterByLocalProvider( - profile.Dynamic.Routers, + data.Routers, func(r Router) string { return r.Provider }, ), ) - // Retrieve services - d.Services = merge( + // Fetch services + data.Services = merge( getServices[HTTPService](profile, HTTPServiceAPI), getServices[TCPService](profile, TCPServiceAPI), getServices[UDPService](profile, UDPServiceAPI), filterByLocalProvider( - profile.Dynamic.Services, + data.Services, func(s Service) string { return s.Provider }, ), ) // Fetch middlewares - d.Middlewares = merge( + data.Middlewares = merge( getMiddlewares[HTTPMiddleware](profile, HTTPMiddlewaresAPI), getMiddlewares[TCPMiddleware](profile, TCPMiddlewaresAPI), filterByLocalProvider( - profile.Dynamic.Middlewares, + data.Middlewares, func(m Middleware) string { return m.Provider }, ), ) // Retrieve entrypoints - entrypoints, err := profile.fetch(EntrypointsAPI) + entrypoints, err := fetch(profile, EntrypointsAPI) if err != nil { slog.Error("Failed to get entrypoints", "error", err) return } defer entrypoints.Close() - if err = json.NewDecoder(entrypoints).Decode(&d.Entrypoints); err != nil { + if err = json.NewDecoder(entrypoints).Decode(&data.Entrypoints); err != nil { slog.Error("Failed to decode entrypoints", "error", err) return } // Fetch version - version, err := profile.fetch(VersionAPI) + version, err := fetch(profile, VersionAPI) if err != nil { slog.Error("Failed to get version", "error", err) return @@ -402,14 +424,11 @@ func GetTraefikConfig() { slog.Error("Failed to decode version", "error", err) return } - d.Version = v.Version - - profile.Dynamic = d - ProfileData.Profiles[i] = profile - } - - if err := ProfileData.Save(); err != nil { - slog.Error("Failed to save profiles", "error", err) + data.Version = v.Version + if err := UpdateConfig(config.ProfileID, data); err != nil { + slog.Error("Failed to update config", "error", err) + return + } } } @@ -418,6 +437,7 @@ func Sync() { ticker := time.NewTicker(time.Second * 60) defer ticker.Stop() + GetTraefikConfig() for range ticker.C { GetTraefikConfig() } @@ -444,32 +464,31 @@ func merge[T any](maps ...map[string]T) map[string]T { return merged } -func (p Profile) fetch(endpoint string) (io.ReadCloser, error) { - if p.URL == "" || endpoint == "" { +func fetch(profile db.Profile, endpoint string) (io.ReadCloser, error) { + if profile.Url == "" { return nil, fmt.Errorf("invalid URL or endpoint") } - apiURL := p.URL + endpoint client := http.Client{Timeout: time.Second * 10} - if !p.TLS { + if !profile.Tls { client.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } - req, err := http.NewRequest("GET", apiURL, nil) + req, err := http.NewRequest("GET", profile.Url+endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - if p.Username != "" && p.Password != "" { - req.SetBasicAuth(p.Username, p.Password) + if *profile.Username != "" && *profile.Password != "" { + req.SetBasicAuth(*profile.Username, *profile.Password) } resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("failed to fetch %s: %w", apiURL, err) + return nil, fmt.Errorf("failed to fetch %s: %w", profile.Url+endpoint, err) } if resp.StatusCode != http.StatusOK { diff --git a/pkg/traefik/convert.go b/pkg/traefik/convert.go new file mode 100644 index 0000000..fc5f11a --- /dev/null +++ b/pkg/traefik/convert.go @@ -0,0 +1,91 @@ +package traefik + +import ( + "context" + "encoding/json" + + "github.com/MizuchiLabs/mantrae/internal/db" +) + +func DecodeConfig(config db.Config) (*Dynamic, error) { + data := &Dynamic{ + ProfileID: config.ProfileID, + Entrypoints: make([]Entrypoint, 0), + Routers: make(map[string]Router), + Services: make(map[string]Service), + Middlewares: make(map[string]Middleware), + Version: "", + } + + if config.Entrypoints != nil { + if err := json.Unmarshal(config.Entrypoints.([]byte), &data.Entrypoints); err != nil { + return nil, err + } + } + if config.Routers != nil { + if err := json.Unmarshal(config.Routers.([]byte), &data.Routers); err != nil { + return nil, err + } + } + if config.Services != nil { + if err := json.Unmarshal(config.Services.([]byte), &data.Services); err != nil { + return nil, err + } + } + if config.Middlewares != nil { + if err := json.Unmarshal(config.Middlewares.([]byte), &data.Middlewares); err != nil { + return nil, err + } + } + if config.Version != nil { + data.Version = *config.Version + } + return data, nil +} + +func UpdateConfig(profileID int64, data *Dynamic) error { + for _, r := range data.Routers { + if err := r.Verify(); err != nil { + return err + } + } + for _, s := range data.Services { + if err := s.Verify(); err != nil { + return err + } + } + for _, m := range data.Middlewares { + if err := m.Verify(); err != nil { + return err + } + } + + entrypoints, err := json.Marshal(data.Entrypoints) + if err != nil { + return err + } + routers, err := json.Marshal(data.Routers) + if err != nil { + return err + } + services, err := json.Marshal(data.Services) + if err != nil { + return err + } + middlewares, err := json.Marshal(data.Middlewares) + if err != nil { + return err + } + if _, err := db.Query.UpdateConfig(context.Background(), db.UpdateConfigParams{ + ProfileID: profileID, + Entrypoints: entrypoints, + Routers: routers, + Services: services, + Middlewares: middlewares, + Version: &data.Version, + }); err != nil { + return err + } + + return nil +} diff --git a/pkg/traefik/models.go b/pkg/traefik/models.go index bbd5495..6f9e54d 100644 --- a/pkg/traefik/models.go +++ b/pkg/traefik/models.go @@ -3,26 +3,11 @@ package traefik import ( - "sync" - "github.com/traefik/genconf/dynamic" ) -type Profiles struct { - Profiles map[string]Profile `json:"profiles,omitempty"` - mu sync.RWMutex -} - -type Profile struct { - Name string `json:"name"` - URL string `json:"url"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - TLS bool `json:"tls,omitempty"` - Dynamic Dynamic `json:"dynamic,omitempty"` -} - type Dynamic struct { + ProfileID int64 `json:"profile_id,omitempty"` Entrypoints []Entrypoint `json:"entrypoints,omitempty"` Routers map[string]Router `json:"routers,omitempty"` Services map[string]Service `json:"services,omitempty"` diff --git a/pkg/traefik/profile.go b/pkg/traefik/profile.go deleted file mode 100644 index 780f85d..0000000 --- a/pkg/traefik/profile.go +++ /dev/null @@ -1,149 +0,0 @@ -package traefik - -import ( - "encoding/json" - "fmt" - "io" - "log" - "log/slog" - "os" - "path/filepath" - "strings" -) - -// Global profiles variable, only loaded once -var ProfileData = Profiles{ - Profiles: make(map[string]Profile), -} - -func init() { - if err := ProfileData.Load(); err != nil { - log.Fatalf("Failed to load profiles: %v", err) - } -} - -func (p *Profiles) Load() error { - p.mu.RLock() - cwd, err := os.Getwd() - if err != nil { - log.Fatal(err) - } - - path := filepath.Join(cwd, "profiles.json") - if _, err = os.Stat(path); os.IsNotExist(err) { - p.Profiles = make(map[string]Profile) - p.Profiles["default"] = Profile{Name: "default"} - p.mu.RUnlock() - if err = p.Save(); err != nil { - slog.Error("Failed to save profiles", "error", err) - } - return nil - } - - file, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read profiles file: %w", err) - } - - if err := json.Unmarshal(file, &p.Profiles); err != nil { - return fmt.Errorf("failed to unmarshal profiles: %w", err) - } - - p.mu.RUnlock() - return nil -} - -func (p *Profiles) Save() error { - p.mu.Lock() - defer p.mu.Unlock() - cwd, err := os.Getwd() - if err != nil { - log.Fatal(err) - } - - path := filepath.Join(cwd, "profiles.json") - - tmpFile, err := os.CreateTemp(os.TempDir(), "profiles-*.json") - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - defer os.Remove(tmpFile.Name()) - - profileBytes, err := json.Marshal(p.Profiles) - if err != nil { - return fmt.Errorf("failed to marshal profiles: %w", err) - } - - _, err = tmpFile.Write(profileBytes) - if err != nil { - return fmt.Errorf("failed to write profiles: %w", err) - } - - if err := tmpFile.Sync(); err != nil { - return fmt.Errorf("failed to sync temp file: %w", err) - } - tmpFile.Close() - - if err := Move(tmpFile.Name(), path); err != nil { - return fmt.Errorf("failed to move temp file: %w", err) - } - - return nil -} - -func (p *Profiles) SetDefaultProfile(url, username, password string) error { - if err := p.Load(); err != nil { - return fmt.Errorf("failed to load profiles: %w", err) - } - if len(p.Profiles) == 0 { - p.Profiles = make(map[string]Profile) - } - - p.Profiles["default"] = Profile{ - Name: "default", - URL: url, - Username: username, - Password: password, - TLS: false, - } - - return p.Save() -} - -func Move(source, destination string) error { - err := os.Rename(source, destination) - if err != nil && strings.Contains(err.Error(), "invalid cross-device link") { - return moveCrossDevice(source, destination) - } - return err -} - -func moveCrossDevice(source, destination string) error { - src, err := os.Open(source) - if err != nil { - return fmt.Errorf("failed to open source file: %w", err) - } - dst, err := os.Create(destination) - if err != nil { - src.Close() - return fmt.Errorf("failed to create destination file: %w", err) - } - _, err = io.Copy(dst, src) - src.Close() - dst.Close() - if err != nil { - return fmt.Errorf("failed to copy file: %w", err) - } - fi, err := os.Stat(source) - if err != nil { - os.Remove(destination) - return fmt.Errorf("failed to stat source file: %w", err) - } - err = os.Chmod(destination, fi.Mode()) - if err != nil { - os.Remove(destination) - return fmt.Errorf("failed to chmod destination file: %w", err) - } - os.Remove(source) - return nil -} diff --git a/pkg/traefik/verify.go b/pkg/traefik/verify.go index 87e72fb..088bf67 100644 --- a/pkg/traefik/verify.go +++ b/pkg/traefik/verify.go @@ -12,21 +12,6 @@ import ( "github.com/MizuchiLabs/mantrae/pkg/util" ) -func (p *Profile) Verify() error { - if p.Name == "" { - return fmt.Errorf("profile name cannot be empty") - } - - if p.URL != "" { - if !isValidURL(p.URL) { - return fmt.Errorf("invalid url") - } - } else { - return fmt.Errorf("url cannot be empty") - } - return nil -} - func (r *Router) Verify() error { if r.Name == "" { return fmt.Errorf("name cannot be empty") @@ -50,6 +35,10 @@ func (s *Service) Verify() error { if s.ServiceType == "" { return fmt.Errorf("service type cannot be empty") } + if len(s.LoadBalancer.Servers) == 0 || len(s.TCPLoadBalancer.Servers) == 0 || + len(s.UDPLoadBalancer.Servers) == 0 { + return fmt.Errorf("servers cannot be empty") + } s.Provider = "http" s.Name = validateName(s.Name, s.Provider) diff --git a/pkg/util/secrets.go b/pkg/util/secrets.go index 7fe5989..c1a0720 100644 --- a/pkg/util/secrets.go +++ b/pkg/util/secrets.go @@ -2,82 +2,47 @@ package util import ( + "context" "crypto/rand" "encoding/base64" - "encoding/json" "log/slog" - "os" - "path/filepath" + + "github.com/MizuchiLabs/mantrae/internal/db" ) -type Credentials struct { - Username string `json:"username"` - Password string `json:"password"` - Secret []byte `json:"secret"` -} - -func randomPassword(length int) []byte { +func randomPassword(length int) string { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { - return nil + return "" } - return bytes + return base64.RawURLEncoding.EncodeToString(bytes)[:length] } -func GenerateCreds() error { - cwd, err := os.Getwd() +func SetDefaultAdminUser() { + // check if default admin user exists + creds, err := db.Query.GetCredentialByUsername(context.Background(), "admin") if err != nil { - return err - } - credsPath := filepath.Join(cwd, "creds.json") - if _, err = os.Stat(credsPath); os.IsNotExist(err) { - username := "admin" - password := base64.StdEncoding.EncodeToString(randomPassword(32)) - - jsonCreds, err := json.MarshalIndent(Credentials{ - Username: username, + password := randomPassword(32) + if err := db.Query.CreateCredential(context.Background(), db.CreateCredentialParams{ + Username: "admin", Password: password, - Secret: randomPassword(64), - }, "", " ") - if err != nil { - return err + }); err != nil { + slog.Error("Failed to create default admin user", "error", err) } - - if err := os.WriteFile(credsPath, jsonCreds, 0600); err != nil { - return err - } - - slog.Info("Generated new credentials", "username", username, "password", password) + slog.Info("Generated default admin user", "username", "admin", "password", password) + return } - return nil -} - -// GetCreds retrieves the credentials from the creds.json file or generates a new one -func (c *Credentials) GetCreds() error { - cwd, err := os.Getwd() - if err != nil { - return err - } - credsPath := filepath.Join(cwd, "creds.json") - - file, err := os.ReadFile(credsPath) - if err != nil { - return err - } - - if err := json.Unmarshal(file, &c); err != nil { - return err - } - - if c.Username == "" || c.Password == "" || c.Secret == nil { + // Validate credentials + if creds.Username != "admin" || creds.Password == "" { + password := randomPassword(32) slog.Info("Invalid credentials, regenerating...") - if err := os.Remove(credsPath); err != nil { - return err - } - if err := GenerateCreds(); err != nil { - return err + if err := db.Query.UpdateCredential(context.Background(), db.UpdateCredentialParams{ + Username: "admin", + Password: password, + }); err != nil { + slog.Error("Failed to update default admin user", "error", err) } + slog.Info("Generated default admin user", "username", "admin", "password", password) } - return nil } diff --git a/sqlc.yml b/sqlc.yml new file mode 100644 index 0000000..74c5320 --- /dev/null +++ b/sqlc.yml @@ -0,0 +1,13 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "internal/db/query.sql" + schema: "internal/db/schema.sql" + gen: + go: + package: "db" + out: "internal/db" + emit_json_tags: true + emit_prepared_queries: true + emit_interface: true + emit_pointers_for_null_types: true diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c246f87..e683d49 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,43 +1,42 @@ import { goto } from '$app/navigation'; import { toast } from 'svelte-sonner'; -import type { Profile } from './types/dynamic'; -import type { Middleware } from './types/middlewares'; +import type { Config, Profile } from './types/dynamic'; +import { newMiddleware, type Middleware } from './types/middlewares'; import { derived, get, writable, type Writable } from 'svelte/store'; import { newRouter, newService, type Router, type Service } from './types/config'; import type { Provider } from './types/provider'; export const loggedIn = writable(false); export const profile: Writable = writable(); -export const profiles: Writable> = writable(); -export const provider: Writable> = writable(); +export const config: Writable = writable(); +export const profiles: Writable = writable(); +export const provider: Writable = writable(); export const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3000/api'; -export const routers = derived(profile, ($profile) => - Object.values($profile?.dynamic?.routers ?? []) -); -export const services = derived(profile, ($profile) => - Object.values($profile?.dynamic?.services ?? []) -); -export const middlewares = derived(profile, ($profile) => - Object.values($profile?.dynamic?.middlewares ?? []) -); -export const version = derived(profile, ($profile) => $profile?.dynamic?.version ?? ''); -export const entrypoints = derived(profile, ($profile) => $profile?.dynamic?.entrypoints ?? []); +export const routers = derived(config, ($config) => Object.values($config?.routers ?? [])); +export const services = derived(config, ($config) => Object.values($config?.services ?? [])); +export const middlewares = derived(config, ($config) => Object.values($config?.middlewares ?? [])); +export const version = derived(config, ($config) => $config?.version ?? ''); +export const entrypoints = derived(config, ($config) => $config?.entrypoints ?? []); -async function handleRequest(endpoint: string, method: string, body?: any): Promise { +async function handleRequest( + endpoint: string, + method: string, + body?: any +): Promise { if (!get(loggedIn)) return; const token = localStorage.getItem('token'); - try { - const response = await fetch(`${API_URL}${endpoint}`, { - method: method, - body: body ? JSON.stringify(body) : undefined, - headers: { Authorization: `Bearer ${token}` } - }); - return await response.json(); - } catch (e: any) { + const response = await fetch(`${API_URL}${endpoint}`, { + method: method, + body: body ? JSON.stringify(body) : undefined, + headers: { Authorization: `Bearer ${token}` } + }); + if (response.status === 200) { + return response; + } else { toast.error('Request failed', { - description: e.message, + description: await response.text(), duration: 3000 }); } @@ -45,20 +44,20 @@ async function handleRequest(endpoint: string, method: string, body?: any): Prom // Login ---------------------------------------------------------------------- export async function login(username: string, password: string) { - try { - const response = await fetch(`${API_URL}/login`, { - method: 'POST', - body: JSON.stringify({ username, password }) - }); + const response = await fetch(`${API_URL}/login`, { + method: 'POST', + body: JSON.stringify({ username, password }) + }); + if (response.status === 200) { const { token } = await response.json(); localStorage.setItem('token', token); loggedIn.set(true); await getProfiles(); goto('/'); toast.success('Login successful'); - } catch (e: any) { + } else { toast.error('Login failed', { - description: e.message, + description: await response.text(), duration: 3000 }); return; @@ -72,134 +71,198 @@ export async function logout() { // Profiles ------------------------------------------------------------------- export async function getProfiles() { - const response = await handleRequest('/profiles', 'GET'); - profiles.set(response); - - // Get saved profile - const savedProfile = localStorage.getItem('profile'); - if (savedProfile !== null) { - getProfile(savedProfile); - } - if (!get(profile) && Object.keys(response).length > 0) { - getProfile(Object.keys(response)[0]); - } -} - -export async function getProfile(name: string) { - const response = await handleRequest('/profile/' + name, 'GET'); - profile.set(response); - localStorage.setItem('profile', response.name); -} - -export async function createProfile(profile: Profile): Promise { - const response = await handleRequest('/profiles', 'POST', profile); + const response = await handleRequest('/profile', 'GET'); if (response) { - profiles.set(response); - toast.success(`Profile ${profile.name} created`); - } -} + let data = await response.json(); + profiles.set(data); -export async function updateProfile(name: string, p: Profile): Promise { - if (!get(profile)) return; - const response = await handleRequest(`/profiles/${get(profile).name}`, 'PUT', p); - if (response) { - profile.set(response); - toast.success(`Profile ${name} updated`); - if (get(profile).name === name) { - localStorage.setItem('profile', response.name); + // Get saved profile + const profileID = parseInt(localStorage.getItem('profile') ?? ''); + if (profileID) { + getProfile(profileID); + return; + } + if (data === undefined) return; + if (!get(profile) && data.length > 0) { + getProfile(data[0].id); } } } -export async function deleteProfile(name: string): Promise { - const response = await handleRequest(`/profiles/${name}`, 'DELETE'); +export async function getProfile(id: number) { + const respProfile = await handleRequest(`/profile/${id}`, 'GET'); + if (respProfile) { + let data = await respProfile.json(); + profile.set(data); + localStorage.setItem('profile', data.id.toString()); + } else { + localStorage.removeItem('profile'); + return; + } + + const respConfig = await handleRequest(`/config/${id}`, 'GET'); + if (respConfig) { + let data = await respConfig.json(); + config.set(data); + } +} + +export async function createProfile(p: Profile): Promise { + const response = await handleRequest('/profile', 'POST', p); if (response) { - profiles.set(response); - toast.success(`Profile ${name} deleted`); - if (get(profile).name === name) { + let data = await response.json(); + profiles.update((items) => [...(items ?? []), data]); + toast.success(`Profile ${data.name} created`); + + const profileID = parseInt(localStorage.getItem('profile') ?? ''); + if (!profileID) { + localStorage.setItem('profile', data.id.toString()); + profile.set(data); + } + } +} + +export async function updateProfile(p: Profile): Promise { + const response = await handleRequest(`/profile`, 'PUT', p); + if (response) { + let data = await response.json(); + profile.set(data); + profiles.update((items) => items.map((i) => (i.id === p.id ? data : i))); + toast.success(`Profile ${data.name} updated`); + + if (get(profile).id === data.id) { + localStorage.setItem('profile', data.id.toString()); + } + } +} + +export async function deleteProfile(p: Profile): Promise { + const response = await handleRequest(`/profile/${p.id}`, 'DELETE', p); + if (response) { + profiles.update((items) => items.filter((i) => i.id !== p.id)); + toast.success(`Profile deleted`); + + if (get(profile).id === p.id) { profile.set({} as Profile); localStorage.removeItem('profile'); } } } -// Providers ------------------------------------------------------------------ +// Provider ------------------------------------------------------------------- export async function getProviders() { - const response = await handleRequest('/providers', 'GET'); - provider.set(response); -} - -export async function updateProvider(oldName: string, p: Provider): Promise { - const response = await handleRequest(`/providers/${oldName}`, 'PUT', p); - provider.set(response); - toast.success(`Provider ${p.name} updated`); -} - -export async function deleteProvider(name: string): Promise { - const response = await handleRequest(`/providers/${name}`, 'DELETE'); - provider.set(response); - toast.success(`Provider ${name} deleted`); -} - -// Routers -------------------------------------------------------------------- -export async function updateRouter( - oldName: string, - router: Router, - service: Service -): Promise { - if (!get(profile)) return; - - const resRouter = await handleRequest(`/routers/${get(profile).name}/${oldName}`, 'PUT', router); - if (resRouter) { - const resService = await handleRequest( - `/services/${get(profile).name}/${oldName}`, - 'PUT', - service - ); - if (resService) { - profile.set(resService); - toast.success(`Router ${router.name} updated`); - } + const response = await handleRequest('/provider', 'GET'); + if (response) { + let data = await response.json(); + provider.set(data); } } -export async function deleteRouter(name: string): Promise { - if (!get(profile)) return; - await handleRequest(`/routers/${get(profile).name}/${name}`, 'DELETE'); - const response = await handleRequest(`/services/${get(profile).name}/${name}`, 'DELETE'); - profile.set(response); - toast.success(`Router ${name} deleted`); +export async function getProvider(id: number): Promise { + const response = await handleRequest(`/provider/${id}`, 'GET'); + if (response) { + let data = await response.json(); + return data; + } + return {} as Provider; } -// Middlewares ---------------------------------------------------------------- -export async function updateMiddleware( - middleware: Middleware, - oldMiddleware: string -): Promise { - if (!get(profile)) return; - const response = await handleRequest( - `/middlewares/${get(profile).name}/${oldMiddleware}`, - 'PUT', - middleware - ); - - profile.set(response); - toast.success(`Middleware ${middleware.name} updated`); +export async function createProvider(p: Provider): Promise { + const response = await handleRequest('/provider', 'POST', p); + if (response) { + let data = await response.json(); + provider.update((items) => [...(items ?? []), data]); + toast.success(`Provider ${data.name} created`); + } } -export async function deleteMiddleware(name: string): Promise { - if (!get(profile)) return; - const response = await handleRequest(`/middlewares/${get(profile).name}/${name}`, 'DELETE'); - profile.set(response); - toast.success(`Middleware ${name} deleted`); +export async function updateProvider(p: Provider): Promise { + const response = await handleRequest(`/provider`, 'PUT', p); + if (response) { + let data = await response.json(); + provider.update((items) => items.map((i) => (i.id === p.id ? data : i))); + toast.success(`Provider ${data.name} updated`); + } +} + +export async function deleteProvider(id: number): Promise { + const response = await handleRequest(`/provider/${id}`, 'DELETE'); + if (response) { + provider.update((items) => items.filter((i) => i.id !== id)); + toast.success(`Provider deleted`); + } +} + +// Config --------------------------------------------------------------------- +export async function updateConfig(c: Config): Promise { + const response = await handleRequest(`/config/${get(profile).id}`, 'PUT', c); + if (response) { + let data = await response.json(); + config.set(data); + toast.success(`Config updated`); + } } // Helper functions ----------------------------------------------------------- export function getRouter(routerName: string): Router { - const router = get(profile)?.dynamic?.routers?.[routerName]; + const router = get(config)?.routers?.[routerName]; return router ?? newRouter(); } export function getService(serviceName: string): Service { - const service = get(profile)?.dynamic?.services?.[serviceName]; + const service = get(config)?.services?.[serviceName]; return service ?? newService(); } +export function getMiddleware(middlewareName: string): Middleware { + const middleware = get(config)?.middlewares?.[middlewareName]; + return middleware ?? newMiddleware(); +} + +function nameCheck(name: string): string { + return name.split('@')[0].toLowerCase() + '@http'; +} + +// Create or update a router +export async function upsertRouter(name: string, router: Router, service: Service): Promise { + let data = get(config); + if (!data.routers) data.routers = {}; + if (!data.services) data.services = {}; + + if (router.name !== name) { + delete data.routers[name]; + delete data.services[name]; + } + router.name = nameCheck(router.name); + service.name = router.name; + data.routers[router.name] = router; + data.services[router.name] = service; + await updateConfig(data); +} + +// Create or update a middleware +export async function upsertMiddleware(name: string, middleware: Middleware): Promise { + let data = get(config); + if (!data.middlewares) data.middlewares = {}; + if (middleware.name !== name) { + delete data.middlewares[name]; + } + middleware.name = nameCheck(middleware.name); + data.middlewares[middleware.name] = middleware; + await updateConfig(data); +} + +// Delete a router by name +export async function deleteRouter(name: string): Promise { + let data = get(config); + if (!data.routers || !data.services) return; + delete data.routers[name]; + delete data.services[name]; + await updateConfig(data); +} + +// Delete a middleware by name +export async function deleteMiddleware(name: string): Promise { + let data = get(config); + if (!data.middlewares) return; + delete data.middlewares[name]; + await updateConfig(data); +} diff --git a/web/src/lib/components/modals/createMiddleware.svelte b/web/src/lib/components/modals/createMiddleware.svelte index 400054b..ef7d308 100644 --- a/web/src/lib/components/modals/createMiddleware.svelte +++ b/web/src/lib/components/modals/createMiddleware.svelte @@ -7,7 +7,7 @@ import { Label } from '$lib/components/ui/label/index.js'; import { Switch } from '$lib/components/ui/switch/index.js'; import type { Selected } from 'bits-ui'; - import { updateMiddleware, middlewares } from '$lib/api'; + import { upsertMiddleware, middlewares } from '$lib/api'; import { newMiddleware } from '$lib/types/middlewares'; import { LoadMiddlewareForm } from '../utils/middlewareModules'; import { onMount, SvelteComponent } from 'svelte'; @@ -15,13 +15,12 @@ let middleware = newMiddleware(); let isHTTP = middleware.middlewareType === 'http'; - const create = () => { + const create = async () => { if (middleware.type === '' || middleware.name === '' || isNameTaken) return; - middleware.name = middleware.name + '@' + middleware.provider; if (isHTTP) middleware.middlewareType = 'http'; else middleware.middlewareType = 'tcp'; - updateMiddleware(middleware, middleware.name); + await upsertMiddleware(middleware.name, middleware); middleware = newMiddleware(); middlewareType = HTTPMiddlewareTypes[0]; }; diff --git a/web/src/lib/components/modals/createProfile.svelte b/web/src/lib/components/modals/createProfile.svelte index b33096f..55fa634 100644 --- a/web/src/lib/components/modals/createProfile.svelte +++ b/web/src/lib/components/modals/createProfile.svelte @@ -4,24 +4,21 @@ import { Button } from '$lib/components/ui/button/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; - import { profiles, createProfile } from '$lib/api'; + import { createProfile } from '$lib/api'; import { newProfile } from '$lib/types/dynamic'; let profile = newProfile(); - const create = () => { - if (profile.name === '' || isNameTaken) return; + const create = async () => { + if (profile.name === '') return; // Strip trailing slashes if (profile.url.endsWith('/')) { profile.url = profile.url.slice(0, -1); } - createProfile(profile); + await createProfile(profile); profile = newProfile(); }; - let isNameTaken = false; - $: isNameTaken = Object.keys($profiles).some((p) => p === profile.name); - const onKeydown = (e: KeyboardEvent) => { if (e.key === 'Enter') { create(); @@ -50,9 +47,7 @@ name="name" type="text" bind:value={profile.name} - class={isNameTaken - ? 'col-span-3 border-red-400 focus-visible:ring-0 focus-visible:ring-offset-0' - : 'col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0'} + class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0" placeholder="Your profile name" required /> diff --git a/web/src/lib/components/modals/createProvider.svelte b/web/src/lib/components/modals/createProvider.svelte index 2b51a65..83a3c92 100644 --- a/web/src/lib/components/modals/createProvider.svelte +++ b/web/src/lib/components/modals/createProvider.svelte @@ -7,7 +7,7 @@ import { Button } from '$lib/components/ui/button'; import { newProvider, type Provider } from '$lib/types/provider'; import type { Selected } from 'bits-ui'; - import { updateProvider } from '$lib/api'; + import { createProvider } from '$lib/api'; let provider: Provider = newProvider(); const providerTypes: Selected[] = [ @@ -15,15 +15,15 @@ { label: 'PowerDNS', value: 'powerdns' } ]; - const create = () => { + const create = async () => { if ( provider.name === '' || provider.type === '' || - provider.key === '' || - provider.externalIP === '' + provider.api_key === '' || + provider.external_ip === '' ) return; - updateProvider(provider.name, provider); + await createProvider(provider); provider = newProvider(); providerType = providerTypes[0]; }; @@ -84,7 +84,7 @@ type="text" placeholder="The public IP address of the traefik instance" class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0" - bind:value={provider.externalIP} + bind:value={provider.external_ip} required /> @@ -96,7 +96,7 @@ type="text" placeholder="http://127.0.0.1:8081" class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0" - bind:value={provider.url} + bind:value={provider.api_url} required /> @@ -107,7 +107,7 @@ name="key" type="password" class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0" - bind:value={provider.key} + bind:value={provider.api_key} placeholder="API Key of the provider" required /> diff --git a/web/src/lib/components/modals/createRouter.svelte b/web/src/lib/components/modals/createRouter.svelte index 01c8454..9d767c6 100644 --- a/web/src/lib/components/modals/createRouter.svelte +++ b/web/src/lib/components/modals/createRouter.svelte @@ -7,7 +7,7 @@ import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import type { Selected } from 'bits-ui'; - import { routers, entrypoints, middlewares, updateRouter } from '$lib/api'; + import { routers, entrypoints, middlewares, upsertRouter } from '$lib/api'; import { newRouter, newService, type Router } from '$lib/types/config'; import RuleEditor from '../utils/ruleEditor.svelte'; import Service from '../forms/service.svelte'; @@ -17,8 +17,7 @@ const create = async () => { if (router.name === '' || isNameTaken) return; - service.name = router.name.split('@')[0] + '@' + router.provider; - await updateRouter(router.name, router, service); + await upsertRouter(router.name, router, service); router = newRouter(); service = newService(); diff --git a/web/src/lib/components/modals/updateMiddleware.svelte b/web/src/lib/components/modals/updateMiddleware.svelte index 7cfa14e..6e7a24b 100644 --- a/web/src/lib/components/modals/updateMiddleware.svelte +++ b/web/src/lib/components/modals/updateMiddleware.svelte @@ -5,27 +5,26 @@ import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import { Badge } from '$lib/components/ui/badge/index.js'; - import { deleteMiddleware, updateMiddleware, middlewares } from '$lib/api'; + import { deleteMiddleware, upsertMiddleware, middlewares } from '$lib/api'; import type { Middleware } from '$lib/types/middlewares'; import { LoadMiddlewareForm } from '../utils/middlewareModules'; import { onMount, type SvelteComponent } from 'svelte'; export let middleware: Middleware; - let name = middleware.name.split('@')[0]; + let originalName = middleware.name; let middlewareCompare = $middlewares.filter((m) => m.name !== middleware.name); let open = false; - const update = () => { + const update = async () => { if (middleware.name === '' || isNameTaken) return; - let oldName = middleware.name; - middleware.name = name + '@' + middleware.provider; - updateMiddleware(middleware, oldName); + await upsertMiddleware(originalName, middleware); + originalName = middleware.name; open = false; }; // Check if middleware name is taken unless self let isNameTaken = false; - $: isNameTaken = middlewareCompare.some((m) => m.name === name + '@' + middleware.provider); + $: isNameTaken = middlewareCompare.some((m) => m.name === middleware.name); const onKeydown = (e: KeyboardEvent) => { if (e.key === 'Enter') { @@ -70,7 +69,7 @@ id="name" name="name" type="text" - bind:value={name} + bind:value={middleware.name} on:keydown={onKeydown} class={isNameTaken ? 'col-span-3 border-red-400 focus-visible:ring-0 focus-visible:ring-offset-0' diff --git a/web/src/lib/components/modals/updateProfile.svelte b/web/src/lib/components/modals/updateProfile.svelte index f3fa654..4e9309f 100644 --- a/web/src/lib/components/modals/updateProfile.svelte +++ b/web/src/lib/components/modals/updateProfile.svelte @@ -4,22 +4,20 @@ import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import { Switch } from '$lib/components/ui/switch/index.js'; - import { deleteProfile, profiles, updateProfile } from '$lib/api'; + import { deleteProfile, updateProfile } from '$lib/api'; + import type { Profile } from '$lib/types/dynamic'; - export let name: string; + export let profile: Profile; let open = false; - let profileCompare = Object.keys($profiles).filter((p) => p !== name); - const update = () => { + const update = async () => { // Strip trailing slashes - if ($profiles[name].url.endsWith('/')) { - $profiles[name].url = $profiles[name].url.slice(0, -1); + if (profile.url.endsWith('/')) { + profile.url = profile.url.slice(0, -1); } - updateProfile(name, $profiles[name]); + await updateProfile(profile); open = false; }; - let isNameTaken = false; - $: isNameTaken = profileCompare.some((p) => p === $profiles[name].name); const onKeydown = (e: KeyboardEvent) => { if (e.key === 'Enter') { update(); @@ -45,7 +43,7 @@ type="text" class="col-span-3" placeholder="Your profile name" - bind:value={$profiles[name].name} + bind:value={profile.name} required /> @@ -55,7 +53,7 @@ name="url" type="text" class="col-span-3" - bind:value={$profiles[name].url} + bind:value={profile.url} placeholder="URL of your client" required /> @@ -66,7 +64,7 @@ name="username" type="text" class="col-span-3" - bind:value={$profiles[name].username} + bind:value={profile.username} placeholder="Username of your client" required /> @@ -77,18 +75,18 @@ name="password" type="password" class="col-span-3" - bind:value={$profiles[name].password} + bind:value={profile.password} placeholder="Password of your client" required />
- +
- diff --git a/web/src/lib/components/modals/updateProvider.svelte b/web/src/lib/components/modals/updateProvider.svelte index e57b603..a056b40 100644 --- a/web/src/lib/components/modals/updateProvider.svelte +++ b/web/src/lib/components/modals/updateProvider.svelte @@ -2,31 +2,29 @@ import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Card from '$lib/components/ui/card/index.js'; import { Button } from '$lib/components/ui/button/index.js'; + import { Badge } from '$lib/components/ui/badge/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import type { Provider } from '$lib/types/provider'; - import { updateProvider, provider } from '$lib/api'; + import { updateProvider } from '$lib/api'; export let p: Provider; let open = false; - let oldName = p.name; - let providerCompare = Object.keys($provider).filter((prov) => prov !== p.name); const update = async () => { - if (p.name === '' || p.type === '' || p.key === '' || p.externalIP === '') return; + if (p.name === '' || p.type === '' || p.api_key === '' || p.external_ip === '') return; // check if url starts with http:// or https:// - if (p.type === 'powerdns' && !p.url?.startsWith('http://') && !p.url?.startsWith('https://')) { - p.url = 'http://' + p.url; + if ( + p.type === 'powerdns' && + !p.api_url?.startsWith('http://') && + !p.api_url?.startsWith('https://') + ) { + p.api_url = 'http://' + p.api_url; } - updateProvider(oldName, p); - oldName = p.name; + await updateProvider(p); open = false; }; - // Check if provider name is taken - let isNameTaken = false; - $: isNameTaken = providerCompare.some((prov) => prov === p.name); - const onKeydown = (e: KeyboardEvent) => { if (e.key === 'Enter') { update(); @@ -43,7 +41,14 @@ - DNS Provider + + DNS Provider +
+ + {p.type} + +
+
Update your DNS provider.
@@ -54,9 +59,7 @@ name="name" type="text" bind:value={p.name} - class={isNameTaken - ? 'col-span-3 border-red-400 focus-visible:ring-0 focus-visible:ring-offset-0' - : 'col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0'} + class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0" placeholder="Your profile name" required /> @@ -68,7 +71,7 @@ type="text" placeholder="The public IP address of the traefik instance" class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0" - bind:value={p.externalIP} + bind:value={p.external_ip} required /> @@ -80,7 +83,7 @@ type="text" placeholder="http://127.0.0.1:8081" class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0" - bind:value={p.url} + bind:value={p.api_url} required /> @@ -91,7 +94,7 @@ name="key" type="password" class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0" - bind:value={p.key} + bind:value={p.api_key} placeholder="API Key of the provider" required /> diff --git a/web/src/lib/components/modals/updateRouter.svelte b/web/src/lib/components/modals/updateRouter.svelte index 090d3c7..fc65955 100644 --- a/web/src/lib/components/modals/updateRouter.svelte +++ b/web/src/lib/components/modals/updateRouter.svelte @@ -7,22 +7,29 @@ import { Badge } from '$lib/components/ui/badge/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; - import { routers, entrypoints, middlewares, updateRouter, getService } from '$lib/api'; - import { newService, type Router } from '$lib/types/config'; + import { + routers, + entrypoints, + middlewares, + getService, + upsertRouter, + deleteRouter + } from '$lib/api'; + import { type Router } from '$lib/types/config'; import RuleEditor from '../utils/ruleEditor.svelte'; import type { Selected } from 'bits-ui'; import Service from '../forms/service.svelte'; export let router: Router; - let service = getService(router.name) ?? newService(); + let originalName = router.name; + let service = getService(router.name); let routerCompare = $routers.filter((r) => r.name !== router.name); let open = false; - const update = () => { + const update = async () => { if (router.name === '' || isNameTaken) return; - // Extra check in case router name changed - service.name = router.name.split('@')[0] + '@' + router.provider; - updateRouter(router.service, router, service); + await upsertRouter(originalName, router, service); + originalName = router.name; open = false; }; @@ -166,8 +173,9 @@ - - + + +
diff --git a/web/src/lib/components/nav/profile.svelte b/web/src/lib/components/nav/profile.svelte index 68225d2..8b2b8cf 100644 --- a/web/src/lib/components/nav/profile.svelte +++ b/web/src/lib/components/nav/profile.svelte @@ -8,8 +8,8 @@ import { profiles, profile, getProfile } from '$lib/api'; let open = false; - function handleProfileClick(name: string) { - getProfile(name); + function handleProfileClick(id: number) { + getProfile(id); open = false; } @@ -32,12 +32,12 @@ No profile found. - {#each Object.keys($profiles) ?? [] as name} + {#each $profiles ?? [] as profile} - handleProfileClick(name)} aria-hidden - >{name} handleProfileClick(profile.id)} aria-hidden + >{profile.name} - + {/each} diff --git a/web/src/lib/components/nav/sidebar.svelte b/web/src/lib/components/nav/sidebar.svelte index 036c4e4..2344086 100644 --- a/web/src/lib/components/nav/sidebar.svelte +++ b/web/src/lib/components/nav/sidebar.svelte @@ -12,12 +12,12 @@ }, { name: 'Middlewares', - path: '/middlewares', + path: '/middlewares/', icon: 'fa6-solid:layer-group' }, { name: 'DNS', - path: '/dns', + path: '/dns/', icon: 'fa6-solid:earth-americas' } ]; diff --git a/web/src/lib/types/dynamic.ts b/web/src/lib/types/dynamic.ts index 735a998..74d97a3 100644 --- a/web/src/lib/types/dynamic.ts +++ b/web/src/lib/types/dynamic.ts @@ -3,12 +3,21 @@ import type { Middleware } from './middlewares'; import type { CertAndStores, Options, Store } from './tls'; export interface Profile { + id: number; name: string; url: string; username: string; password: string; tls: boolean; - dynamic?: Dynamic; +} + +export interface Config { + profile_id: number; + entrypoints?: Entrypoint[]; + routers?: Record; + services?: Record; + middlewares?: Record; + version?: string; } export interface Dynamic { @@ -37,11 +46,11 @@ export interface TLSConfiguration { export const newProfile = (): Profile => { return { + id: 0, name: '', url: '', username: '', password: '', - tls: false, - dynamic: {} + tls: false }; }; diff --git a/web/src/lib/types/provider.ts b/web/src/lib/types/provider.ts index 9867634..6eb71bd 100644 --- a/web/src/lib/types/provider.ts +++ b/web/src/lib/types/provider.ts @@ -1,17 +1,19 @@ export interface Provider { + id: number; name: string; type: string; - externalIP: string; - key?: string; - url?: string; + external_ip: string; + api_key?: string; + api_url?: string; } export function newProvider(): Provider { return { + id: 0, name: '', type: 'cloudflare', - externalIP: '', - key: '', - url: '' + external_ip: '', + api_key: '', + api_url: '' }; } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index bbfeda1..3e47e94 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -28,7 +28,7 @@ >
- diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 720ea9d..8b1fd4d 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -10,8 +10,8 @@ middlewares, routers, services, - updateRouter, - getService + getService, + upsertRouter } from '$lib/api'; import CreateRouter from '$lib/components/modals/createRouter.svelte'; import UpdateRouter from '$lib/components/modals/updateRouter.svelte'; @@ -47,7 +47,7 @@ ? router.provider?.toLowerCase() === part.split(':')[1] : part.startsWith('@type:') ? router.routerType.toLowerCase() === part.split(':')[1] - : router.service.toLowerCase().includes(part) + : router.name.toLowerCase().includes(part) ); }); @@ -86,13 +86,13 @@ if (item === undefined) return; router.entrypoints = item.map((i) => i.value) as string[]; let service = getService(router.name); - updateRouter(router.name, router, service); + upsertRouter(router.name, router, service); }; const toggleMiddleware = (router: Router, item: Selected[] | undefined) => { if (item === undefined) return; router.middlewares = item.map((i) => i.value) as string[]; let service = getService(router.name); - updateRouter(router.name, router, service); + upsertRouter(router.name, router, service); }; const getSelectedEntrypoints = (router: Router): Selected[] => { let list = router?.entrypoints?.map((entrypoint) => { diff --git a/web/src/routes/dns/+page.svelte b/web/src/routes/dns/+page.svelte index 8d009f5..1e7f415 100644 --- a/web/src/routes/dns/+page.svelte +++ b/web/src/routes/dns/+page.svelte @@ -4,29 +4,38 @@ import { Button } from '$lib/components/ui/button'; import CreateProvider from '$lib/components/modals/createProvider.svelte'; import UpdateProvider from '$lib/components/modals/updateProvider.svelte'; - import { deleteProvider, provider } from '$lib/api'; + import { deleteProvider, getProviders, provider } from '$lib/api'; + import { onMount } from 'svelte'; + + onMount(() => { + if ($provider === undefined) { + getProviders(); + } + });
- {#each Object.values($provider ?? []) as p} - - - - {p.name} - - {p.type} - - - - - - - - - - {/each} + {#if $provider} + {#each $provider as p} + + + + {p.name} + + {p.type} + + + + + + + + + + {/each} + {/if}