diff --git a/.vscode/launch.json b/.vscode/launch.json index 6e1e186..d134001 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,8 @@ "program": "${workspaceFolder}", "env": { "HOST_IP": "127.0.0.1", - "UPNP_PATH": "test.upnp" + "UPNP_PATH": "test.upnp", + "CONFIG_PATH": "config.json" } } ] diff --git a/auth/auth.go b/auth/auth.go index d6b6e6d..d59dbb8 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -7,8 +7,6 @@ import ( "net/http" "time" - // "acoolname.co/backend/models" - "acooldomain.co/backend/models" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" @@ -17,7 +15,8 @@ import ( "golang.org/x/crypto/bcrypt" ) -var hmacSampleSecret []byte +var secret []byte +var method string type Connection struct { connection *mongo.Client @@ -33,9 +32,15 @@ type AuthClaims struct { TokenInfo } +type InviteToken struct { + Email string `bson:"Email"` + Permissions []models.Permission `bson:"Permissions"` + Token string `bson:"Token"` +} + func signToken(token TokenInfo) (string, error) { - t := jwt.New(jwt.GetSigningMethod("HS512")) + t := jwt.New(jwt.GetSigningMethod(method)) t.Claims = &AuthClaims{ &jwt.StandardClaims{ @@ -44,7 +49,7 @@ func signToken(token TokenInfo) (string, error) { token, } - return t.SignedString(hmacSampleSecret) + return t.SignedString(secret) } func hashPassword(password string) (string, error) { @@ -67,7 +72,7 @@ func AuthorizedTo(requiredPermissions models.Permission, overwriters ...func(*gi } // hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key") - return hmacSampleSecret, nil + return secret, nil }) if err != nil { ctx.AbortWithError(403, err) @@ -90,14 +95,15 @@ func AuthorizedTo(requiredPermissions models.Permission, overwriters ...func(*gi } } +type SignUpRequest struct { + Token string + Username string + Password string +} + func (con Connection) signUp(c *gin.Context) { var token TokenInfo - type SignUpRequest struct { - token string - username string - password string - } err := json.NewDecoder(c.Request.Body).Decode(&token) if err != nil { c.AbortWithError(500, err) @@ -163,10 +169,13 @@ func (con Connection) test(c *gin.Context) { c.IndentedJSON(http.StatusOK, claims) } -func LoadGroup(group *gin.RouterGroup, client *mongo.Client) { +func LoadGroup(group *gin.RouterGroup, client *mongo.Client, config models.GlobalConfig) { connection := Connection{connection: client} - group.POST("/signin", connection.signIn) + secret = []byte(config.Key) + method = config.Algorithm + + group.POST("/signin", connection.signIn) group.POST("/signup", AuthorizedTo(models.Admin), connection.signUp) group.GET("/test", AuthorizedTo(models.Admin), connection.test) } diff --git a/go.mod b/go.mod index 2451fcc..461d7db 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect diff --git a/go.sum b/go.sum index 8ecd1de..aa215f0 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzq github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/go.work b/go.work index 8f6a054..b8ab28d 100644 --- a/go.work +++ b/go.work @@ -5,6 +5,7 @@ use ( ./auth ./users ./db_handler + ./mail ./models ./servers ) diff --git a/mail/go.mod b/mail/go.mod new file mode 100644 index 0000000..09147e1 --- /dev/null +++ b/mail/go.mod @@ -0,0 +1,3 @@ +module acooldomain.co/backend/mail + +go 1.22.0 diff --git a/mail/mail.go b/mail/mail.go new file mode 100644 index 0000000..f23b8a3 --- /dev/null +++ b/mail/mail.go @@ -0,0 +1,87 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "net/mail" + "net/smtp" + + "acooldomain.co/backend/models" +) + +const EMAIL_SERVER_ENV_VAR = "EMAIL_SERVER" +const FROM_EMAIL_ENV_VAR = "FROM_EMAIL" + +var auth *smtp.Auth +var mailConfig *models.EmailConfig + +func InitializeClient(config models.EmailConfig) error { + simpleAuth := smtp.PlainAuth("", config.Username, config.Password, config.Server) + auth = &simpleAuth + mailConfig = &config + return nil +} + +func SendMail( + recipient string, + subject string, + content string, +) error { + if auth == nil { + return fmt.Errorf("mail not initialized") + } + if mailConfig == nil { + return fmt.Errorf("mail not initialized") + } + from := mail.Address{Name: "", Address: mailConfig.FromEmail} + to := mail.Address{Name: "", Address: recipient} + + headers := make(map[string]string) + headers["From"] = from.String() + headers["To"] = to.String() + headers["Subject"] = subject + + message := "" + for k, v := range headers { + message += fmt.Sprintf("%s: %s\r\n", k, v) + } + message += "\r\n" + content + + conn, err := tls.Dial("tcp", mailConfig.Server+":465", &tls.Config{ServerName: mailConfig.Server}) + if err != nil { + return err + } + + client, err := smtp.NewClient(conn, mailConfig.Server) + if err != nil { + return err + } + + if err = client.Auth(*auth); err != nil { + return err + } + + if err = client.Mail(mailConfig.FromEmail); err != nil { + return err + } + + if err = client.Rcpt(recipient); err != nil { + return err + } + + writer, err := client.Data() + if err != nil { + return err + } + + _, err = writer.Write([]byte(message)) + if err != nil { + return err + } + + if err = writer.Close(); err != nil { + return err + } + + return client.Quit() +} diff --git a/main.go b/main.go index ef4b4ee..a62df7d 100644 --- a/main.go +++ b/main.go @@ -2,18 +2,40 @@ package main import ( "context" + "encoding/json" + "os" "acooldomain.co/backend/auth" "acooldomain.co/backend/dbhandler" + "acooldomain.co/backend/mail" + "acooldomain.co/backend/models" "acooldomain.co/backend/servers" "acooldomain.co/backend/users" "github.com/gin-gonic/gin" ) +const CONFIG_PATH_ENV_VAR = "CONFIG_PATH" +const MONGO_URL_ENV_VAR = "MONGO_URL" + func main() { router := gin.Default() + file, err := os.Open(os.Getenv(CONFIG_PATH_ENV_VAR)) + if err != nil { + panic(err) + } - client, err := dbhandler.Connect("mongodb://localhost:27017") + var config models.GlobalConfig + err = json.NewDecoder(file).Decode(&config) + + if err != nil { + panic(err) + } + mongo_url := os.Getenv(MONGO_URL_ENV_VAR) + if mongo_url == "" { + mongo_url = "mongodb://localhost:27017" + } + + client, err := dbhandler.Connect(mongo_url) defer func() { if err = client.Disconnect(context.TODO()); err != nil { panic(err) @@ -23,9 +45,12 @@ func main() { if err != nil { panic(err) } - users.LoadGroup(router.Group("/users"), client) - auth.LoadGroup(router.Group("/auth"), client) - servers.LoadGroup(router.Group("/servers"), client) + mail.InitializeClient(config.Email) - router.Run("localhost:8080") + users.LoadGroup(router.Group("/users"), client, config) + auth.LoadGroup(router.Group("/auth"), client, config) + servers.LoadGroup(router.Group("/servers"), client, config) + servers.LoadBrowsersGroup(router.Group("/browsers"), client, config) + + router.Run("127.0.0.1:8080") } diff --git a/models/config.go b/models/config.go new file mode 100644 index 0000000..ffd5426 --- /dev/null +++ b/models/config.go @@ -0,0 +1,14 @@ +package models + +type EmailConfig struct { + FromEmail string + Username string + Password string + Server string +} + +type GlobalConfig struct { + Email EmailConfig + Key string + Algorithm string +} diff --git a/models/server.go b/models/server.go index 11dae6f..ef89925 100644 --- a/models/server.go +++ b/models/server.go @@ -25,6 +25,7 @@ type FileBrowserInfo struct { Id string OwnerId string ConnectedTo ServerInfo + Url string } type ServerData struct { diff --git a/servers/browsers.go b/servers/browsers.go new file mode 100644 index 0000000..e242297 --- /dev/null +++ b/servers/browsers.go @@ -0,0 +1,116 @@ +package servers + +import ( + "context" + "encoding/json" + "fmt" + + "acooldomain.co/backend/auth" + "acooldomain.co/backend/models" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/volume" + "github.com/docker/docker/client" + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/mongo" +) + +func (con Connection) getBrowserInfo(volume volume.Volume) (*models.FileBrowserInfo, error) { + serverInfo, err := con.getServerInfo(volume) + if err != nil { + return nil, err + } + + containers, err := con.dockerClient.ContainerList(context.TODO(), container.ListOptions{ + All: true, + Filters: filters.NewArgs(filters.Arg("label", "type=FILE_BROWSER"), filters.Arg("label", fmt.Sprintf("volume_id=%s", volume.Name))), + }) + if err != nil || len(containers) == 0 { + return nil, nil + } + + container := containers[0] + jsonData, err := json.Marshal(container.Labels) + if err != nil { + return nil, err + } + + var browserInfo ContainerLabels + err = json.Unmarshal(jsonData, &browserInfo) + + if err != nil { + return nil, err + } + + return &models.FileBrowserInfo{ + Id: container.ID, + OwnerId: browserInfo.OwnerId, + ConnectedTo: *serverInfo, + Url: container.ID[:12] + "." + DOMAIN, + }, nil + +} + +func (con Connection) GetBrowsers(ctx *gin.Context) { + volumes, err := con.dockerClient.VolumeList( + context.TODO(), + volume.ListOptions{ + Filters: filters.NewArgs(filters.Arg("label", "type=GAME")), + }, + ) + if err != nil { + ctx.AbortWithError(500, err) + } + + var servers []models.FileBrowserInfo + + for _, volume := range volumes.Volumes { + browserInfo, err := con.getBrowserInfo(*volume) + if err != nil { + ctx.AbortWithError(500, err) + } + if browserInfo == nil { + continue + } + + servers = append(servers, *browserInfo) + } + + if err != nil { + ctx.AbortWithError(500, err) + } + + ctx.JSON(200, servers) +} + +func (con Connection) StopBrowser(ctx *gin.Context) { + serverId := ctx.Param("server_id") + containersList, err := con.dockerClient.ContainerList(context.TODO(), container.ListOptions{ + Filters: filters.NewArgs(filters.Arg("label", "volume_id="+serverId), filters.Arg("label", "type=FILE_BROWSER")), + }) + if err != nil { + ctx.AbortWithError(500, err) + return + } + if len(containersList) == 0 { + ctx.Status(200) + return + } + + for _, containerData := range containersList { + con.dockerClient.ContainerStop(context.TODO(), containerData.ID, container.StopOptions{}) + } + ctx.Status(200) +} + +func LoadBrowsersGroup(group *gin.RouterGroup, mongo_client *mongo.Client, config models.GlobalConfig) { + apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + panic(err) + } + defer apiClient.Close() + + connection := Connection{databaseConnection: mongo_client, dockerClient: apiClient} + group.GET("/", auth.AuthorizedTo(0), connection.GetBrowsers) + group.POST("/:server_id/stop", auth.AuthorizedTo(models.Browse, connection.serverAuthorized(models.Browse)), connection.StopBrowser) +} diff --git a/servers/servers.go b/servers/servers.go index 21cd8ee..549330c 100644 --- a/servers/servers.go +++ b/servers/servers.go @@ -591,36 +591,6 @@ func (con Connection) AttachServer(ctx *gin.Context) { } } -func (con Connection) serverAuthorized(permissions models.Permission) func(*gin.Context) bool { - return func(ctx *gin.Context) bool { - claims, exists := ctx.Get("claims") - if !exists { - return false - } - - server_id := ctx.Param("server_id") - if server_id == "" { - return false - } - - var serverData models.ServerData - - con.databaseConnection.Database("Backend").Collection("Servers").FindOne(context.TODO(), bson.D{{Key: "Id", Value: server_id}}).Decode(&serverData) - - if serverData.OwnerId == claims.(*auth.AuthClaims).Username { - return true - } - - userPermissions := serverData.UserPermissions[claims.(*auth.AuthClaims).Username] - - if userPermissions&permissions == permissions || userPermissions&models.Admin == models.Admin { - return true - } - - return false - } -} - type UpdateServerRequest struct { DefaultPorts []models.Port `json:"DefaultPorts"` DefaultCommand string `json:"DefaultCommand"` @@ -669,17 +639,21 @@ func (con Connection) BrowseServer(ctx *gin.Context) { if err != nil { ctx.AbortWithError(500, err) } - + labelId := serverInfo.Id[:12] browserLabels := make(map[string]string) - browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.domains[0].main", serverInfo.Id)] = fmt.Sprintf("%s.%s", "browsers", DOMAIN) - browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.domains[0].sans", serverInfo.Id)] = fmt.Sprintf("*.%s.%s", "browsers", DOMAIN) - browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", serverInfo.Id)] = "myresolver" + browserLabels["traefik.enable"] = "true" + browserLabels[fmt.Sprintf("traefik.http.routers.%s.rule", labelId)] = fmt.Sprintf("Host(`%s.{service_type}.{DOMAIN}`)", labelId) + browserLabels[fmt.Sprintf("traefik.http.routers.%s.entrypoints", labelId)] = "websecure" + browserLabels[fmt.Sprintf("traefik.http.routers.%s.middlewares", labelId)] = "games@docker" + browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.domains[0].main", labelId)] = fmt.Sprintf("%s.%s", "browsers", DOMAIN) + browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.domains[0].sans", labelId)] = fmt.Sprintf("*.%s.%s", "browsers", DOMAIN) + browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", labelId)] = "myresolver" containerLabels := ContainerLabels{ OwnerId: claims.(*auth.AuthClaims).Username, ImageId: FILE_BROWSER_IMAGE, VolumeId: serverInfo.Id, - Type: "FILE-BROWSER", + Type: "FILE_BROWSER", } jsonLabels, err := json.Marshal(containerLabels) if err != nil { @@ -706,17 +680,16 @@ func (con Connection) BrowseServer(ctx *gin.Context) { } if len(images) == 0 { - ctx.AbortWithError(500, fmt.Errorf("Image %s no longer exists", imageRef)) + ctx.AbortWithError(500, fmt.Errorf("image %s no longer exists", imageRef)) return } ContainerResponse, err := con.dockerClient.ContainerCreate( context.TODO(), &container.Config{ - Cmd: command, - Image: FILE_BROWSER_IMAGE, - Labels: browserLabels, - ExposedPorts: nat.PortSet{"80/tcp": struct{}{}}, + Cmd: command, + Image: FILE_BROWSER_IMAGE, + Labels: browserLabels, }, &container.HostConfig{ Mounts: []mount.Mount{{Source: serverInfo.Id, Target: "/tmp/data", Type: "volume"}}, @@ -745,7 +718,37 @@ func (con Connection) BrowseServer(ctx *gin.Context) { ctx.JSON(200, "OK") } -func LoadGroup(group *gin.RouterGroup, mongo_client *mongo.Client) { +func (con Connection) serverAuthorized(permissions models.Permission) func(*gin.Context) bool { + return func(ctx *gin.Context) bool { + claims, exists := ctx.Get("claims") + if !exists { + return false + } + + server_id := ctx.Param("server_id") + if server_id == "" { + return false + } + + var serverData models.ServerData + + con.databaseConnection.Database("Backend").Collection("Servers").FindOne(context.TODO(), bson.D{{Key: "Id", Value: server_id}}).Decode(&serverData) + + if serverData.OwnerId == claims.(*auth.AuthClaims).Username { + return true + } + + userPermissions := serverData.UserPermissions[claims.(*auth.AuthClaims).Username] + + if userPermissions&permissions == permissions || userPermissions&models.Admin == models.Admin { + return true + } + + return false + } +} + +func LoadGroup(group *gin.RouterGroup, mongo_client *mongo.Client, config models.GlobalConfig) { apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { panic(err) diff --git a/users/users.go b/users/users.go index 3f8a249..cd50d80 100644 --- a/users/users.go +++ b/users/users.go @@ -2,11 +2,14 @@ package users import ( "context" + "encoding/json" "net/http" "acooldomain.co/backend/auth" + "acooldomain.co/backend/mail" "acooldomain.co/backend/models" "github.com/gin-gonic/gin" + "github.com/google/uuid" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" ) @@ -31,7 +34,31 @@ func (con Connection) GetUsers(c *gin.Context) { c.IndentedJSON(http.StatusOK, response) } -func LoadGroup(group *gin.RouterGroup, client *mongo.Client) { +type InviteUser struct { + Email string `json:"Email"` + Permissions []models.Permission `json:"Permissions"` +} + +func (con Connection) InviteUser(c *gin.Context) { + var request InviteUser + json.NewDecoder(c.Request.Body).Decode(&request) + token := uuid.NewString() + + err := mail.SendMail(request.Email, "You've been invited to join", "please open this link https://games.acooldomain.co/signup?token="+token) + if err != nil { + c.AbortWithError(500, err) + return + } + con.connection.Database("Backend").Collection("Tokens").InsertOne(context.TODO(), auth.InviteToken{ + Email: request.Email, + Permissions: request.Permissions, + Token: token, + }) + c.JSON(200, "OK") +} + +func LoadGroup(group *gin.RouterGroup, client *mongo.Client, config models.GlobalConfig) { connection := Connection{connection: client} group.GET("/", auth.AuthorizedTo(0), connection.GetUsers) + group.POST("/", auth.AuthorizedTo(models.Admin), connection.InviteUser) }