diff --git a/Dockerfile b/Dockerfile index 1130828..083b135 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,6 @@ COPY *.go ./ # run the build RUN CGO_ENABLED=0 GOOS=linux go build -o /ginrcon - - # Run the tests in the container FROM build-stage AS run-test-stage RUN go test -v ./... @@ -23,5 +21,11 @@ WORKDIR / COPY --from=build-stage /ginrcon /ginrcon +ENV PORT=8080 \ + TRUSTED_PROXIES= \ + RCON_SERVER= \ + RCON_PORT= \ + RCON_ADMIN_PASSWORD= + EXPOSE 8080 ENTRYPOINT ["/ginrcon"] diff --git a/README.md b/README.md index f148d8a..e09a147 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,39 @@ # GinRCON A lightweight, simple Go webserver build on Gin to provide a REST API frontend to sending RCON commands to a game server. -## Calling the API - +## API Call Examples +### Send command to default RCON server +Assuming you have a default RCON server specified (see environment variables) +```bash +curl --location 'http://localhost:8080/command' \ +--header 'Content-Type: application/json' \ +--data '{ + "command": "Save" +}' +``` +### POST command to specific RCON server +```bash +curl --location 'http://localhost:8080/command' \ +--header 'Content-Type: application/json' \ +--data '{ + "server": "gameserver:25575", + "password": "1234", + "command": "Save" +}' +``` +### Health check the status of the webserver +```bash +curl --location 'http://localhost:8080/status' +``` ## Docker ### Image -A docker image that runs this application is available, `ghcr.io/holysoles/ginrcon`. +A docker image that runs this application is available under the repo packages, or at `ghcr.io/holysoles/ginrcon`. ### Environment Variables -- `PORT`: Optional, override the port of the webserver within the container +- `PORT`: Optional, override the port (default 8080) of the webserver within the container - `TRUSTED_PROXIES`: Optional, set specified trusted proxy addresses +- `RCON_SERVER`: Optional, configure a default RCON server's hostname +- `RCON_PORT`: Optional, configure a default RCON server's port +- `RCON_ADMIN_PASSWORD`: Optional, configure the password to a default RCON server ### Compose An example compose file can be found in the repo [here](https://github.com/holysoles/ginrcon/blob/master/compose.yaml) ### Building diff --git a/compose.yaml b/compose.yaml index 97212cb..94a3b3a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,4 +7,7 @@ services: - "8085:8080/tcp" environment: PORT: 8080 - TRUSTED_PROXIES: "192.168.1.2" \ No newline at end of file + TRUSTED_PROXIES: "192.168.1.2" + RCON_SERVER: gameserver + RCON_PORT: 25575 + RCON_ADMIN_PASSWORD: flyhigh \ No newline at end of file diff --git a/main.go b/main.go index 10afee0..b4f8c54 100644 --- a/main.go +++ b/main.go @@ -1,30 +1,63 @@ package main import ( + "errors" + "fmt" "net" "net/http" "os" "strings" - "time" "github.com/gin-gonic/gin" - "github.com/go-playground/validator/v10" "github.com/gorcon/rcon" ) -type rconInfo struct { - Server string `form:"server" json:"server" xml:"server" binding:"required"` - Password string `form:"password" json:"password" xml:"password" binding:"required"` - Command string `form:"command" json:"command" xml:"command" binding:"required"` +var ( + gConn *rcon.Conn + + ErrNoDefaultConnection = errors.New("no connection details were specified and no valid default connection exists") + ErrInvalidConnectionDetails = errors.New("invalid connection details to the rcon server were provided") + ErrInvalidResponseFromRcon = errors.New("an invalid response was received from the rcon server") +) + +type openConnInfo struct { + Server string `form:"server" json:"server" xml:"server"` + Password string `form:"password" json:"password" xml:"password"` } -type rconReply struct { - Message string `json:"message"` +type commandReq struct { + openConnInfo + Command string `form:"command" json:"command" xml:"command" binding:"required"` } -type errorResponse struct { - Error string +type commandRes struct { + Message string `json:"message"` } func main() { + initDefaultRcon() + initWeb() +} + +func initDefaultRcon() { + var err error + rconHostPort := net.JoinHostPort(os.Getenv("RCON_SERVER"), os.Getenv("RCON_PORT")) + rconAdminPass := os.Getenv("RCON_ADMIN_PASSWORD") + info := openConnInfo{Server: rconHostPort, Password: rconAdminPass} + + //check env vars to construct + gConn, err = openRcon(info) + //log error as a warning, if we have bad default info just throw it away + if err == ErrInvalidConnectionDetails { + fmt.Println("default RCON server connection details were not provided or invalid") + return + } else if err != nil { + fmt.Println(err) + return + } else { + fmt.Println("successfully opened default RCON connection to", rconHostPort) + } +} + +func initWeb() { gin.SetMode(gin.ReleaseMode) r := gin.Default() r.ForwardedByClientIP = true @@ -34,36 +67,45 @@ func main() { r.GET("/status", healthCheck) r.POST("/command", processCommand) - r.Run(":" + os.Getenv("PORT")) + bindPort, customBind := os.LookupEnv("PORT") + if customBind { + r.Run(":" + bindPort) + } else { + r.Run() + } } func processCommand(c *gin.Context) { - var info rconInfo + var info commandReq err := c.Bind(&info) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } - - err = info.validateConnectionInfo() - if err != nil { - c.JSON(http.StatusBadGateway, errorResponse{"Failed to test TCP connection to provided server"}) - return - } - - conn, err := rcon.Dial(info.Server, info.Password) - if err != nil { - switch err { - case rcon.ErrAuthNotRCON: - case rcon.ErrInvalidAuthResponse: - c.AbortWithStatus(http.StatusInternalServerError) - case rcon.ErrAuthFailed: - fallthrough - default: - c.AbortWithStatus(http.StatusUnauthorized) + // if they passed connection info, we should try to create a new connection + var conn *rcon.Conn + if info.openConnInfo.Server != "" { + fmt.Println("using provided connection info for incoming request") + conn, err = openRcon(info.openConnInfo) + if err != nil { + switch err { + case ErrInvalidResponseFromRcon: + c.AbortWithError(http.StatusInternalServerError, err) + case ErrInvalidConnectionDetails: + c.AbortWithError(http.StatusUnauthorized, err) + default: //assume we screwed up + c.AbortWithError(http.StatusInternalServerError, err) + } + return } + defer conn.Close() + } else if gConn != nil { + fmt.Println("using default server connection for incoming request") + conn = gConn + } else { + // no valid default connection and no credentials provided + c.AbortWithError(http.StatusBadGateway, ErrNoDefaultConnection) } - msg, err := conn.Execute(info.Command) if err != nil { switch err { @@ -74,26 +116,13 @@ func processCommand(c *gin.Context) { default: c.AbortWithStatus(http.StatusInternalServerError) } + return } - res := rconReply{msg} + res := commandRes{msg} c.JSON(http.StatusOK, res) } -func (i *rconInfo) validateConnectionInfo() error { - validate := validator.New(validator.WithRequiredStructEnabled()) - err := validate.Var(i.Server, "required,hostname_port") - if err != nil { - return err - } - dialer := &net.Dialer{Timeout: time.Second * 1} - _, err = dialer.Dial("tcp", i.Server) - if err != nil { - return err - } - return nil -} - func healthCheck(c *gin.Context) { c.Status(http.StatusOK) } diff --git a/rcon_helpers.go b/rcon_helpers.go new file mode 100644 index 0000000..840c8af --- /dev/null +++ b/rcon_helpers.go @@ -0,0 +1,47 @@ +package main + +import ( + "net" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gorcon/rcon" +) + +func openRcon(i openConnInfo) (*rcon.Conn, error) { + // basic validation so we can return more verbose error messages + err := i.validateConnectionInfo() + if err != nil { + return nil, ErrInvalidConnectionDetails + } + + //actually open + conn, err := rcon.Dial(i.Server, i.Password) + if err != nil { + switch err { + case rcon.ErrAuthNotRCON: + fallthrough + case rcon.ErrInvalidAuthResponse: + return nil, ErrInvalidResponseFromRcon + case rcon.ErrAuthFailed: + fallthrough + default: + return nil, ErrInvalidConnectionDetails + } + } + return conn, nil +} + +func (i *openConnInfo) validateConnectionInfo() error { + validate := validator.New(validator.WithRequiredStructEnabled()) + err := validate.Var(i.Server, "required,hostname_port") + if err != nil { + return err + } + dialer := &net.Dialer{Timeout: time.Second * 1} + _, err = dialer.Dial("tcp", i.Server) + if err != nil { + return err + } + return nil +}