package servers import ( "context" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" "acooldomain.co/backend/auth" "acooldomain.co/backend/models" "github.com/buildkite/shellwords" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" v1 "github.com/opencontainers/image-spec/specs-go/v1" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" ) const FILE_BROWSER_IMAGE = "filebrowser/filebrowser:latest" var DOMAIN = os.Getenv("SERVER_DOMAIN") type Connection struct { databaseConnection *mongo.Client dockerClient *client.Client } type ContainerLabels struct { OwnerId string `json:"user_id"` ImageId string `json:"image_id"` VolumeId string `json:"volume_id"` Type string `json:"type"` } type VolumeLabels struct { OwnerId string `json:"user_id"` ImageId string `json:"image_id"` Type string `json:"type"` } type ImageLabels struct { Type string `json:"type"` } var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return true }, } func (con Connection) getServerInfo(volume volume.Volume) (*models.ServerInfo, error) { var volumeLabels VolumeLabels var serverData models.ServerData con.databaseConnection.Database("Backend").Collection("Servers").FindOne(context.TODO(), bson.D{{Key: "Id", Value: volume.Name}}).Decode(&serverData) jsonData, err := json.Marshal(volume.Labels) if err != nil { return nil, err } err = json.Unmarshal(jsonData, &volumeLabels) if err != nil { return nil, err } var imagePorts []models.Port if len(serverData.DefaultPorts) == 0 { imageList, err := con.dockerClient.ImageList(context.TODO(), image.ListOptions{Filters: filters.NewArgs(filters.Arg("reference", volumeLabels.ImageId), filters.Arg("label", "type=GAME"))}) if err != nil { return nil, err } if len(imageList) == 0 { return nil, fmt.Errorf("ImageId %s does not exist", volumeLabels.ImageId) } imageSummery := imageList[0] imageInspect, _, err := con.dockerClient.ImageInspectWithRaw(context.TODO(), imageSummery.ID) if err != nil { return nil, err } imagePorts = make([]models.Port, len(imageInspect.Config.ExposedPorts)) i := 0 for imagePort := range imageInspect.Config.ExposedPorts { imagePorts[i] = models.Port{Protocol: imagePort.Proto(), Number: imagePort.Int()} i += 1 } } else { imagePorts = serverData.DefaultPorts } imageNameAndVersion := strings.Split(volumeLabels.ImageId, ":") imageName := imageNameAndVersion[0] imageVersion := imageNameAndVersion[1] containers, err := con.dockerClient.ContainerList(context.TODO(), container.ListOptions{ All: true, Filters: filters.NewArgs(filters.Arg("label", "type=GAME"), filters.Arg("label", fmt.Sprintf("volume_id=%s", volume.Name))), }) var state bool var ports []models.Port if err != nil || len(containers) == 0 { state = false ports = nil } else { container := containers[0] state = container.State == "running" ports = transformContainerPortsToModel(container.Ports) } serverInfo := models.ServerInfo{ Id: volume.Name, Image: models.ImageInfo{ Name: imageName, Version: imageVersion, Ports: imagePorts, }, OwnerId: volumeLabels.OwnerId, On: state, Ports: ports, Nickname: serverData.Nickname, DefaultCommand: serverData.DefaultCommand, } return &serverInfo, nil } func (con Connection) getServerInfoFromId(ServerId string) (*models.ServerInfo, error) { volume, err := con.dockerClient.VolumeInspect(context.TODO(), ServerId) if err != nil { return nil, err } return con.getServerInfo(volume) } func transformContainerPortsToModel(ports []types.Port) []models.Port { modelPorts := make([]models.Port, len(ports)) for index, port := range ports { modelPorts[index] = models.Port{ Number: int(port.PublicPort), Protocol: port.Type, } } return modelPorts } func convertLabelsToMap(v any) (map[string]string, error) { data, err := json.Marshal(v) if err != nil { return nil, err } x := map[string]string{} json.Unmarshal(data, &x) return x, nil } type CreateServerRequest struct { ImageId string `json:"ImageId"` DefaultPorts []models.Port `json:"DefaultPorts"` DefaultCommand string `json:"DefaultCommand"` Nickname string `json:"Nickname"` } func (con Connection) CreateServer(ctx *gin.Context) { claims, exists := ctx.Get("claims") if !exists { ctx.AbortWithStatus(500) return } var request CreateServerRequest err := json.NewDecoder(ctx.Request.Body).Decode(&request) if err != nil { ctx.AbortWithError(400, err) return } imageList, err := con.dockerClient.ImageList(context.TODO(), image.ListOptions{All: true, Filters: filters.NewArgs(filters.Arg("reference", request.ImageId), filters.Arg("label", "type=GAME"))}) if err != nil { ctx.AbortWithError(400, err) return } if len(imageList) == 0 { ctx.AbortWithStatusJSON(404, "imageNotFound") return } imageSummary := imageList[0] labels, err := convertLabelsToMap(VolumeLabels{OwnerId: claims.(*auth.AuthClaims).Username, ImageId: imageSummary.RepoTags[0], Type: "GAME"}) if err != nil { ctx.AbortWithError(400, err) return } volumeResponse, err := con.dockerClient.VolumeCreate(context.TODO(), volume.CreateOptions{ Labels: labels, }) if err != nil { ctx.AbortWithError(500, err) return } con.databaseConnection.Database("Backend").Collection("Servers").InsertOne(context.TODO(), models.ServerData{ Id: volumeResponse.Name, OwnerId: claims.(*auth.AuthClaims).Username, Image: imageSummary.RepoTags[0], VolumeId: volumeResponse.Name, DefaultPorts: request.DefaultPorts, DefaultCommand: request.DefaultCommand, Nickname: request.Nickname, UserPermissions: make(map[string]models.Permission), }) ctx.JSON(200, volumeResponse.Name) } type PortMappingRequest struct { Source models.Port Destination models.Port } type StartServerRequest struct { Command string `json:"Command"` Ports []PortMappingRequest `json:"Ports"` } func (con Connection) StartServer(ctx *gin.Context) { serverId := ctx.Param("server_id") claims, exists := ctx.Get("claims") var request StartServerRequest json.NewDecoder(ctx.Request.Body).Decode(&request) if !exists { ctx.AbortWithStatus(403) return } serverInfo, err := con.getServerInfoFromId(serverId) if err != nil { ctx.AbortWithError(500, err) return } if serverInfo.On { ctx.Status(200) return } imageId := serverInfo.Image.Name + ":" + serverInfo.Image.Version labels := ContainerLabels{ OwnerId: claims.(*auth.AuthClaims).Username, ImageId: imageId, VolumeId: serverInfo.Id, Type: "GAME", } jsonString, err := json.Marshal(labels) if err != nil { ctx.AbortWithError(500, err) return } jsonLabels := make(map[string]string) json.Unmarshal(jsonString, &jsonLabels) volumes := make(map[string]struct{}) image, _, err := con.dockerClient.ImageInspectWithRaw(context.TODO(), imageId) if err != nil { ctx.AbortWithError(500, err) return } var portMapping nat.PortMap = make(nat.PortMap) if len(request.Ports) == 0 { for _, port := range serverInfo.Image.Ports { dockerPort, err := nat.NewPort(port.Protocol, fmt.Sprint(port.Number)) if err != nil { ctx.AbortWithError(500, err) return } portMapping[dockerPort] = []nat.PortBinding{{HostIP: "0.0.0.0"}} } } else { for _, portCouple := range request.Ports { portMapping[nat.Port(fmt.Sprintf("%d/%s", portCouple.Source.Number, portCouple.Source.Protocol))] = []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: fmt.Sprint(portCouple.Destination.Number)}} } } command := request.Command if command == "" { command = serverInfo.DefaultCommand } words, err := shellwords.Split(command) if err != nil { ctx.AbortWithError(500, err) return } if len(words) == 0 { words = nil } portSet := make(nat.PortSet) for port := range portMapping { portSet[port] = struct{}{} } response, err := con.dockerClient.ContainerCreate( context.TODO(), &container.Config{ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, OpenStdin: true, StdinOnce: false, Image: imageId, Volumes: volumes, Labels: jsonLabels, Cmd: words, ExposedPorts: portSet, }, &container.HostConfig{ AutoRemove: true, Mounts: []mount.Mount{{Source: serverInfo.Id, Target: image.Config.WorkingDir, Type: "volume"}}, PortBindings: portMapping, }, &network.NetworkingConfig{}, &v1.Platform{}, "", ) if err != nil { ctx.AbortWithError(500, err) return } if err := con.dockerClient.ContainerStart(ctx, response.ID, container.StartOptions{}); err != nil { ctx.AbortWithError(500, err) return } UPNPPath, exists := os.LookupEnv("UPNP_PATH") HostIP, hostIPexists := os.LookupEnv("HOST_IP") if exists && hostIPexists { time.Sleep(time.Millisecond * 100) containerData, err := con.dockerClient.ContainerInspect(context.TODO(), response.ID) if err != nil { ctx.AbortWithError(500, err) return } fo, err := os.OpenFile(UPNPPath, os.O_APPEND, os.ModeAppend) if err != nil { ctx.AbortWithError(503, err) con.dockerClient.ContainerStop(context.TODO(), containerData.ID, container.StopOptions{}) return } defer func() { if err := fo.Close(); err != nil { panic(err) } }() for containerPort, portBindings := range containerData.NetworkSettings.Ports { for _, hostPort := range portBindings { number, proto := hostPort.HostPort, containerPort.Proto() _, err := fo.Write([]byte(fmt.Sprintf("%s|%s|%s|%s|%s\n", HostIP, number, "0.0.0.0", number, strings.ToUpper(proto)))) if err != nil { ctx.AbortWithError(500, err) return } } } } ctx.JSON(200, response.ID) } func (con Connection) GetServers(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.ServerInfo = make([]models.ServerInfo, 0, len(volumes.Volumes)) for _, volume := range volumes.Volumes { serverInfo, err := con.getServerInfo(*volume) if err != nil { continue } servers = append(servers, *serverInfo) } if err != nil { ctx.AbortWithError(500, err) } ctx.JSON(200, servers) } func (con Connection) StopServer(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=GAME")), }) 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 (con Connection) DeleteServer(ctx *gin.Context) { serverId := ctx.Param("server_id") containers, err := con.dockerClient.ContainerList(context.TODO(), container.ListOptions{All: true, Filters: filters.NewArgs(filters.Arg("label", "volume_id="+serverId))}) if err != nil { ctx.AbortWithError(500, err) return } for _, containerInstance := range containers { err := con.dockerClient.ContainerRemove(context.TODO(), containerInstance.ID, container.RemoveOptions{Force: true}) if err != nil { ctx.AbortWithError(500, err) return } } con.dockerClient.VolumeRemove(context.TODO(), serverId, false) con.databaseConnection.Database("Backend").Collection("Servers").FindOneAndDelete(context.TODO(), bson.D{{Key: "Id", Value: serverId}}) ctx.JSON(200, "ok") } type RunCommandRequest struct { Command string `json:"Command"` } func (con Connection) RunCommand(ctx *gin.Context) { var request RunCommandRequest err := json.NewDecoder(ctx.Request.Body).Decode(&request) if err != nil { ctx.AbortWithError(500, err) } serverId := ctx.Param("server_id") log.Print("Writing command \"", request.Command, "\"") containers, err := con.dockerClient.ContainerList(context.TODO(), container.ListOptions{Filters: filters.NewArgs(filters.Arg("label", "volume_id="+serverId), filters.Arg("label", "type=GAME"))}) if err != nil { ctx.AbortWithError(500, err) return } for _, containerData := range containers { hijacked, err := con.dockerClient.ContainerAttach(context.TODO(), containerData.ID, container.AttachOptions{Stream: true, Stdin: true}) if err != nil { ctx.AbortWithError(500, err) return } defer func() { hijacked.Close(); hijacked.CloseWrite() }() number, err := hijacked.Conn.Write([]byte(request.Command + "\n")) log.Print("Wrote ", number, " bytes") if err != nil { ctx.AbortWithError(500, err) return } } ctx.JSON(200, "OK") } type Commands struct { CommandType string `json:"CommandType"` Arguments []string `json:"Arguments"` } func (con Connection) AttachServer(ctx *gin.Context) { serverId := ctx.Param("server_id") stop := false websocketRead := make(chan Commands) containerRead := make(chan string) defer func() { close(websocketRead) close(containerRead) }() containers, err := con.dockerClient.ContainerList(context.TODO(), container.ListOptions{Filters: filters.NewArgs(filters.Arg("label", "volume_id="+serverId))}) if err != nil { ctx.AbortWithError(500, err) return } if len(containers) == 0 { ctx.AbortWithStatus(404) return } hijacked, err := con.dockerClient.ContainerAttach(context.TODO(), containers[0].ID, container.AttachOptions{Stream: true, Stdin: true, Stdout: true, Stderr: true, Logs: true}) if err != nil { ctx.AbortWithError(500, err) return } defer hijacked.Close() ws, err := upgrader.Upgrade(ctx.Writer, ctx.Request, nil) if err != nil { ctx.AbortWithError(500, err) return } defer ws.Close() go func() { data := make([]byte, 1024) for { if stop { break } count, err := hijacked.Reader.Read(data) if err != nil { stop = true break } if count > 0 { log.Println("Got ", count, " bytes: ", string(data[:count])) containerRead <- string(data[:count]) } } }() go func() { var command Commands for { if stop { break } err := ws.ReadJSON(&command) if err != nil { command.CommandType = "close" websocketRead <- command break } websocketRead <- command } }() for { if stop { break } select { case Command := <-websocketRead: switch Command.CommandType { case "insert": _, err = hijacked.Conn.Write([]byte(Command.Arguments[0])) if err != nil { stop = true break } case "close": stop = true } case data := <-containerRead: err := ws.WriteJSON(data) if err != nil { stop = true } } } } type UpdateServerRequest struct { DefaultPorts []models.Port `json:"DefaultPorts"` DefaultCommand string `json:"DefaultCommand"` Nickname string `json:"Nickname"` UserPermissions map[string][]models.Permission `json:"UserPermissions"` } func (con Connection) UpdateServer(ctx *gin.Context) { serverId := ctx.Param("server_id") var Request UpdateServerRequest err := json.NewDecoder(ctx.Request.Body).Decode(&Request) if err != nil { ctx.AbortWithError(500, err) return } updateOperation := bson.M{} if Request.DefaultCommand != "" { updateOperation["DefaultCommand"] = Request.DefaultCommand } if Request.DefaultPorts != nil { updateOperation["DefaultPorts"] = Request.DefaultPorts } if Request.Nickname != "" { updateOperation["Nickname"] = Request.Nickname } _, err = con.databaseConnection.Database("Backend").Collection("Servers").UpdateOne(context.TODO(), bson.D{{Key: "Id", Value: serverId}}, bson.D{{Key: "$set", Value: updateOperation}}) if err != nil { ctx.AbortWithError(500, err) } ctx.JSON(200, "OK") } func (con Connection) BrowseServer(ctx *gin.Context) { serverID := ctx.Param("server_id") claims, exists := ctx.Get("claims") if !exists { ctx.AbortWithStatus(403) return } serverInfo, err := con.getServerInfoFromId(serverID) if err != nil { ctx.AbortWithError(500, err) } labelId := serverInfo.Id[:12] browserLabels := make(map[string]string) 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", } jsonLabels, err := json.Marshal(containerLabels) if err != nil { ctx.AbortWithError(500, err) return } err = json.Unmarshal(jsonLabels, &browserLabels) if err != nil { ctx.AbortWithError(500, err) return } command, err := shellwords.Split("-r /tmp/data --noauth") if err != nil { ctx.AbortWithError(500, err) return } imageRef := fmt.Sprintf("%s:%s", serverInfo.Image.Name, serverInfo.Image.Version) images, err := con.dockerClient.ImageList(context.TODO(), image.ListOptions{Filters: filters.NewArgs(filters.Arg("label", "type=GAME"), filters.Arg("reference", imageRef))}) if err != nil { ctx.AbortWithError(500, err) return } if len(images) == 0 { ctx.AbortWithError(500, fmt.Errorf("image %s no longer exists", imageRef)) return } browserInfo, err := con.getBrowserInfoFromServerId(serverInfo.Id) if err != nil { ctx.AbortWithError(500, err) return } if browserInfo != nil { ctx.JSON(200, browserInfo.Url) return } ContainerResponse, err := con.dockerClient.ContainerCreate( context.TODO(), &container.Config{ Cmd: command, Image: FILE_BROWSER_IMAGE, Labels: browserLabels, }, &container.HostConfig{ Mounts: []mount.Mount{{Source: serverInfo.Id, Target: "/tmp/data", Type: "volume"}}, AutoRemove: true, }, &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{"test": { NetworkID: "exposed", }}, }, &v1.Platform{}, "", ) if err != nil { ctx.AbortWithError(500, err) return } err = con.dockerClient.ContainerStart(context.TODO(), ContainerResponse.ID, container.StartOptions{}) if err != nil { ctx.AbortWithError(500, err) return } browserInfo, err = con.getBrowserInfoFromServerId(serverInfo.Id) if err != nil { ctx.AbortWithError(500, err) return } if browserInfo == nil { ctx.AbortWithError(500, fmt.Errorf("failed to open browser for server %s", serverInfo.Id)) return } ctx.JSON(200, browserInfo.Url) } func (con Connection) GetServerUserPermissions(ctx *gin.Context) { claims, exists := ctx.Get("claims") if !exists { ctx.AbortWithStatus(500) return } server_id := ctx.Param("server_id") if server_id == "" { ctx.AbortWithStatus(500) return } var serverData models.ServerData con.databaseConnection.Database("Backend").Collection("Servers").FindOne(context.TODO(), bson.D{{Key: "Id", Value: server_id}}).Decode(&serverData) ctx.JSON(200, serverData.UserPermissions[claims.(*auth.AuthClaims).Username]) } type SetServerUserPermissionsRequest struct { Username string Permissions models.Permission } func (con Connection) SetServerUserPermissions(ctx *gin.Context) { server_id := ctx.Param("server_id") if server_id == "" { ctx.AbortWithStatus(500) return } var request SetServerUserPermissionsRequest err := json.NewDecoder(ctx.Request.Body).Decode(&request) if err != nil { ctx.AbortWithError(500, err) return } _, err = con.databaseConnection.Database("Backend").Collection("Servers").UpdateOne(context.TODO(), bson.D{{Key: "Id", Value: server_id}}, bson.D{{Key: "$set", Value: bson.D{{Key: fmt.Sprintf("UserPermissions.%s", request.Username), Value: request.Permissions}}}}) if err != nil { ctx.AbortWithError(500, err) return } ctx.JSON(200, "OK") } 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) } defer apiClient.Close() connection := Connection{databaseConnection: mongo_client, dockerClient: apiClient} authConnection := auth.Connection{DatabaseConnection: mongo_client} group.POST("/:server_id/start", auth.AuthorizedTo(models.Start, authConnection.ServerAuthorized(models.Start)), connection.StartServer) group.POST("/", auth.AuthorizedTo(models.Create), connection.CreateServer) group.GET("/", auth.AuthorizedTo(0), connection.GetServers) group.POST("/:server_id/stop", auth.AuthorizedTo(models.Stop, authConnection.ServerAuthorized(models.Stop)), connection.StopServer) group.DELETE("/:server_id", auth.AuthorizedTo(models.Delete, authConnection.ServerAuthorized(models.Delete)), connection.DeleteServer) group.POST("/:server_id/run_command", auth.AuthorizedTo(models.RunCommand, authConnection.ServerAuthorized(models.RunCommand)), connection.RunCommand) group.GET("/:server_id/attach", auth.AuthorizedTo(models.RunCommand, authConnection.ServerAuthorized(models.RunCommand)), connection.AttachServer) group.PATCH("/:server_id", auth.AuthorizedTo(models.Admin, authConnection.ServerAuthorized(models.Admin)), connection.UpdateServer) group.POST("/:server_id/browse", auth.AuthorizedTo(models.Browse, authConnection.ServerAuthorized(models.Admin)), connection.BrowseServer) group.GET("/:server_id/permissions", auth.AuthorizedTo(models.Browse, authConnection.ServerAuthorized(models.Admin)), connection.GetServerUserPermissions) group.POST("/:server_id/permissions", auth.AuthorizedTo(models.Browse, authConnection.ServerAuthorized(models.Admin)), connection.SetServerUserPermissions) }