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