quorate/packages/grafana-rcon/backend/main.go

140 lines
3.9 KiB
Go
Raw Normal View History

package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"github.com/gorcon/rcon"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/app"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter"
)
const pluginID = "quorate-rcon-app"
type serverConfig struct {
APIURL string `json:"apiUrl"`
Address string `json:"address"`
PasswordPath string `json:"passwordPath"`
}
type command struct {
Command string `json:"command"`
}
type console struct {
backend.CallResourceHandler
allowed func(*http.Request) (bool, error)
execute func(context.Context, string) (string, error)
check func(context.Context) error
}
func newConsole(ctx context.Context, _ backend.AppInstanceSettings) (instancemgmt.Instance, error) {
data, err := os.ReadFile("/etc/grafana/rcon/config.json")
if err != nil {
return nil, err
}
var cfg serverConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg.Address == "" || cfg.PasswordPath == "" || cfg.APIURL == "" {
return nil, errors.New("Grafana API URL, RCON address and password file are required")
}
auth := newAuthorizer(cfg.APIURL)
c := &console{allowed: auth.canSubmit, execute: func(ctx context.Context, cmd string) (string, error) {
password, err := cfg.password()
if err != nil {
return "", err
}
return execute(ctx, cfg.Address, password, cmd)
}, check: func(ctx context.Context) error {
password, err := cfg.password()
if err != nil {
return err
}
return checkRcon(ctx, cfg.Address, password)
}}
c.CallResourceHandler = httpadapter.New(c.routes())
return c, nil
}
func (cfg serverConfig) password() (string, error) {
data, err := os.ReadFile(cfg.PasswordPath)
return strings.TrimSuffix(string(data), "\n"), err
}
func (c *console) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /access", func(w http.ResponseWriter, req *http.Request) {
allowed, err := c.allowed(req)
if err != nil {
http.Error(w, "authorization unavailable", http.StatusServiceUnavailable)
return
}
respond(w, map[string]bool{"submit": allowed})
})
mux.HandleFunc("POST /command", c.submit)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, req *http.Request) {
if !c.authorize(w, req) {
return
}
if err := c.check(req.Context()); err != nil {
http.Error(w, "RCON connection or authentication failed", http.StatusBadGateway)
return
}
respond(w, map[string]string{"status": "ok"})
})
return mux
}
func respond(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(data)
}
func (c *console) authorize(w http.ResponseWriter, req *http.Request) bool {
allowed, err := c.allowed(req)
if err != nil {
http.Error(w, "authorization unavailable", http.StatusServiceUnavailable)
return false
}
if !allowed {
http.Error(w, "permission denied", http.StatusForbidden)
return false
}
return true
}
func (c *console) submit(w http.ResponseWriter, req *http.Request) {
if !c.authorize(w, req) {
return
}
var input command
if err := json.NewDecoder(req.Body).Decode(&input); err != nil {
http.Error(w, "invalid command", http.StatusBadRequest)
return
}
if strings.TrimSpace(input.Command) == "" || len(input.Command) > rcon.MaxCommandLen || strings.ContainsAny(input.Command, "\x00\r\n") {
http.Error(w, "enter one command of 1–1000 bytes", http.StatusBadRequest)
return
}
output, err := c.execute(req.Context(), input.Command)
if err != nil {
http.Error(w, "RCON request failed; execution may have occurred. Check server state before resubmitting.", http.StatusBadGateway)
return
}
respond(w, map[string]string{"output": output})
}
func main() {
if err := app.Manage(pluginID, newConsole, app.ManageOpts{}); err != nil {
os.Exit(1)
}
}