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) } }