140 lines
3.9 KiB
Go
140 lines
3.9 KiB
Go
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)
|
||
}
|
||
}
|