ce7cb702218693d08ea6d83464d2457c9250027c

This commit is contained in:
ms2k 2026-11-12 20:46:28 +09:00
commit 34c5f8963c
6 changed files with 145336 additions and 0 deletions

View file

@ -0,0 +1,66 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/grafana/authlib/authn"
"github.com/grafana/grafana-plugin-sdk-go/config"
)
type authorizer struct {
url string
client *http.Client
verifier authn.Verifier[struct{}]
}
func newAuthorizer(base string) *authorizer {
base = strings.TrimRight(base, "/")
client := &http.Client{Timeout: 10 * time.Second}
keys := authn.NewKeyRetriever(authn.KeyRetrieverConfig{SigningKeysURL: base + "/api/signing-keys/keys"}, authn.WithHTTPClientKeyRetrieverOpt(client))
return &authorizer{
url: base,
client: client,
verifier: authn.NewVerifier[struct{}](authn.VerifierConfig{}, authn.TokenTypeID, keys),
}
}
func (a *authorizer) canSubmit(req *http.Request) (bool, error) {
cfg := config.GrafanaConfigFromContext(req.Context())
token, err := cfg.PluginAppClientSecret()
if err != nil || token == "" {
return false, errors.New("plugin service account is unavailable")
}
claims, err := a.verifier.Verify(req.Context(), req.Header.Get("X-Grafana-Id"))
if err != nil {
return false, err
}
id, ok := strings.CutPrefix(claims.Subject, "user:")
if !ok || id == "" {
return false, nil
}
request, err := http.NewRequestWithContext(req.Context(), "GET", a.url+"/api/users/"+url.PathEscape(id), nil)
if err != nil {
return false, err
}
request.Header.Set("Authorization", "Bearer "+token)
response, err := a.client.Do(request)
if err != nil {
return false, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return false, errors.New("user lookup is unavailable")
}
var profile struct {
Admin bool `json:"isGrafanaAdmin"`
}
if err := json.NewDecoder(response.Body).Decode(&profile); err != nil {
return false, err
}
return profile.Admin, nil
}

View file

@ -0,0 +1,158 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/grafana/grafana-plugin-sdk-go/config"
)
type authFixture struct {
key *ecdsa.PrivateKey
server *httptest.Server
admin atomic.Bool
status atomic.Int32
badJSON atomic.Bool
keyCalls atomic.Int32
userCalls atomic.Int32
bearer atomic.Value
}
func newAuthFixture(t *testing.T) *authFixture {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
f := &authFixture{key: key}
f.admin.Store(true)
f.status.Store(http.StatusOK)
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/signing-keys/keys":
f.keyCalls.Add(1)
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "synthetic"}}})
case "/api/users/17":
f.userCalls.Add(1)
f.bearer.Store(req.Header.Get("Authorization"))
w.WriteHeader(int(f.status.Load()))
if f.badJSON.Load() {
_, _ = w.Write([]byte("invalid JSON"))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"isGrafanaAdmin": f.admin.Load(), "role": "Admin"})
default:
http.NotFound(w, req)
}
}))
t.Cleanup(f.server.Close)
return f
}
func signedID(t *testing.T, key *ecdsa.PrivateKey, subject string, expires time.Time) string {
t.Helper()
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jose.JSONWebKey{Key: key, KeyID: "synthetic"}}, new(jose.SignerOptions).WithType("jwt"))
if err != nil {
t.Fatal(err)
}
token, err := jwt.Signed(signer).Claims(jwt.Claims{Subject: subject, Expiry: jwt.NewNumericDate(expires)}).Serialize()
if err != nil {
t.Fatal(err)
}
return token
}
func authRequest(identity, secret string) *http.Request {
ctx := config.WithGrafanaConfig(context.Background(), config.NewGrafanaCfg(map[string]string{config.AppClientSecret: secret}))
req := httptest.NewRequest("POST", "/command", strings.NewReader(`{"command":"list"}`)).WithContext(ctx)
req.Header.Set("X-Grafana-Id", identity)
req.Header.Set("X-Grafana-User", "forged-admin")
req.Header.Set("X-Grafana-Role", "Admin")
return req
}
func TestRoleRevocation(t *testing.T) {
f := newAuthFixture(t)
auth := newAuthorizer(f.server.URL)
identity := signedID(t, f.key, "user:17", time.Now().Add(time.Minute))
calls := 0
c := &console{allowed: auth.canSubmit, execute: func(context.Context, string) (string, error) { calls++; return "ok", nil }}
for _, step := range []struct {
admin bool
secret string
status int
}{
{true, "synthetic-first", 200},
{false, "synthetic-second", 403},
{false, "synthetic-second", 403},
{true, "synthetic-second", 200},
} {
f.admin.Store(step.admin)
w := httptest.NewRecorder()
c.routes().ServeHTTP(w, authRequest(identity, step.secret))
if w.Code != step.status {
t.Fatalf("status=%d want=%d", w.Code, step.status)
}
}
if calls != 2 || f.keyCalls.Load() != 1 || f.userCalls.Load() != 4 || f.bearer.Load() != "Bearer synthetic-second" {
t.Fatalf("commands=%d keys=%d users=%d bearer=%v", calls, f.keyCalls.Load(), f.userCalls.Load(), f.bearer.Load())
}
}
func TestIdentityFailures(t *testing.T) {
for _, name := range []string{"missing ID", "forged ID", "expired ID", "wrong signature", "service account", "empty user", "missing secret", "user API failure", "invalid profile", "organization admin"} {
t.Run(name, func(t *testing.T) {
f := newAuthFixture(t)
identity := signedID(t, f.key, "user:17", time.Now().Add(time.Minute))
secret := "synthetic-service"
status := http.StatusServiceUnavailable
switch name {
case "missing ID":
identity = ""
case "forged ID":
identity = "admin"
case "expired ID":
identity = signedID(t, f.key, "user:17", time.Now().Add(-time.Hour))
case "wrong signature":
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
identity = signedID(t, key, "user:17", time.Now().Add(time.Minute))
case "service account":
identity = signedID(t, f.key, "service-account:17", time.Now().Add(time.Minute))
status = http.StatusForbidden
case "empty user":
identity = signedID(t, f.key, "user:", time.Now().Add(time.Minute))
status = http.StatusForbidden
case "missing secret":
secret = ""
case "user API failure":
f.status.Store(http.StatusInternalServerError)
case "invalid profile":
f.badJSON.Store(true)
case "organization admin":
f.admin.Store(false)
status = http.StatusForbidden
}
calls := 0
c := &console{allowed: newAuthorizer(f.server.URL).canSubmit, execute: func(context.Context, string) (string, error) { calls++; return "ok", nil }}
w := httptest.NewRecorder()
c.routes().ServeHTTP(w, authRequest(identity, secret))
if w.Code != status || calls != 0 {
t.Fatalf("status=%d want=%d commands=%d", w.Code, status, calls)
}
})
}
}

View file

@ -0,0 +1,140 @@
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)
}
}

View file

@ -0,0 +1,75 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCommandBoundary(t *testing.T) {
for _, tc := range []struct {
name, method, body string
allow bool
authErr error
status, calls int
}{
{"viewer", "POST", `{"command":"stop"}`, false, nil, 403, 0},
{"auth failure", "POST", `{"command":"stop"}`, false, errors.New("offline"), 503, 0},
{"query refresh", "GET", `{"command":"stop"}`, true, nil, 405, 0},
{"empty", "POST", `{"command":" "}`, true, nil, 400, 0},
{"newline", "POST", `{"command":"list\nstop"}`, true, nil, 400, 0},
{"nul", "POST", `{"command":"list\u0000stop"}`, true, nil, 400, 0},
{"oversized", "POST", `{"command":"` + strings.Repeat("x", 1001) + `"}`, true, nil, 400, 0},
{"admin", "POST", `{"command":"list"}`, true, nil, 200, 1},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
c := &console{allowed: func(*http.Request) (bool, error) { return tc.allow, tc.authErr }, execute: func(context.Context, string) (string, error) { calls++; return "Nobody online", nil }}
w := httptest.NewRecorder()
c.routes().ServeHTTP(w, httptest.NewRequest(tc.method, "/command", strings.NewReader(tc.body)))
if w.Code != tc.status || calls != tc.calls {
t.Fatalf("status=%d calls=%d", w.Code, calls)
}
})
}
}
func TestNoRetry(t *testing.T) {
calls := 0
c := &console{allowed: func(*http.Request) (bool, error) { return true, nil }, execute: func(context.Context, string) (string, error) { calls++; return "", errors.New("lost response") }}
w := httptest.NewRecorder()
c.routes().ServeHTTP(w, httptest.NewRequest("POST", "/command", strings.NewReader(`{"command":"say once"}`)))
if w.Code != 502 || calls != 1 {
t.Fatalf("status=%d calls=%d", w.Code, calls)
}
}
func TestHealth(t *testing.T) {
for _, tc := range []struct {
name string
allow bool
err error
status, calls int
}{
{"viewer", false, nil, 403, 0},
{"admin", true, nil, 200, 1},
{"unavailable RCON", true, errors.New("offline"), 502, 1},
} {
t.Run(tc.name, func(t *testing.T) {
checks, commands := 0, 0
c := &console{
allowed: func(*http.Request) (bool, error) { return tc.allow, nil },
check: func(context.Context) error { checks++; return tc.err },
execute: func(context.Context, string) (string, error) { commands++; return "", nil },
}
w := httptest.NewRecorder()
c.routes().ServeHTTP(w, httptest.NewRequest("GET", "/health", nil))
if w.Code != tc.status || checks != tc.calls || commands != 0 {
t.Fatalf("status=%d checks=%d commands=%d", w.Code, checks, commands)
}
})
}
}

View file

@ -0,0 +1,97 @@
package main
import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/gorcon/rcon"
)
const (
authID = 0
cmdID = 1
endID = 2
)
func authenticate(conn net.Conn, password string) error {
if _, err := rcon.NewPacket(rcon.SERVERDATA_AUTH, authID, password).WriteTo(conn); err != nil {
return err
}
var packet rcon.Packet
if _, err := packet.ReadFrom(conn); err != nil {
return err
}
if packet.Type != rcon.SERVERDATA_AUTH_RESPONSE {
return rcon.ErrInvalidAuthResponse
}
if packet.ID == -1 {
return rcon.ErrAuthFailed
}
if packet.ID != authID {
return rcon.ErrInvalidPacketID
}
return nil
}
func withRcon(ctx context.Context, address, password string, action func(net.Conn) (string, error)) (string, error) {
dialer := net.Dialer{Timeout: rcon.DefaultDialTimeout}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return "", err
}
defer conn.Close()
cancel := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer cancel()
if err := conn.SetDeadline(time.Now().Add(rcon.DefaultDeadline)); err != nil {
return "", err
}
if err := authenticate(conn, password); err != nil {
return "", err
}
if err := conn.SetDeadline(time.Now().Add(rcon.DefaultDeadline)); err != nil {
return "", err
}
return action(conn)
}
func checkRcon(ctx context.Context, address, password string) error {
_, err := withRcon(ctx, address, password, func(net.Conn) (string, error) { return "", nil })
return err
}
func execute(ctx context.Context, address, password, command string) (string, error) {
return withRcon(ctx, address, password, func(conn net.Conn) (string, error) {
if _, err := rcon.NewPacket(rcon.SERVERDATA_EXECCOMMAND, cmdID, command).WriteTo(conn); err != nil {
return "", err
}
var first rcon.Packet
if _, err := first.ReadFrom(conn); err != nil {
return "", err
}
if first.ID != cmdID || first.Type != rcon.SERVERDATA_RESPONSE_VALUE {
return "", fmt.Errorf("unexpected RCON response")
}
// Wait for the command response before sending the delimiter; Minecraft drops pipelined requests.
if _, err := rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, endID, "").WriteTo(conn); err != nil {
return "", err
}
var output strings.Builder
output.WriteString(first.Body())
for {
var packet rcon.Packet
if _, err := packet.ReadFrom(conn); err != nil {
return "", err
}
if packet.ID == endID {
return output.String(), nil
}
if packet.ID != cmdID || packet.Type != rcon.SERVERDATA_RESPONSE_VALUE {
return "", fmt.Errorf("unexpected RCON response")
}
output.WriteString(packet.Body())
}
})
}

View file

@ -0,0 +1,128 @@
package main
import (
"bytes"
"context"
"errors"
"net"
"testing"
"time"
"github.com/gorcon/rcon"
)
func TestMultipart(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
done <- err
return
}
defer conn.Close()
var packet rcon.Packet
if _, err := packet.ReadFrom(conn); err != nil {
done <- err
return
}
if _, err := rcon.NewPacket(2, packet.ID, "").WriteTo(conn); err != nil {
done <- err
return
}
if _, err := packet.ReadFrom(conn); err != nil {
done <- err
return
}
commandID := packet.ID
if _, err := rcon.NewPacket(0, commandID, "first packet\n").WriteTo(conn); err != nil {
done <- err
return
}
if _, err := packet.ReadFrom(conn); err != nil {
done <- err
return
}
markerID := packet.ID
for _, response := range []*rcon.Packet{
rcon.NewPacket(0, commandID, "second packet"),
rcon.NewPacket(0, markerID, "Unknown request 0"),
} {
if _, err := response.WriteTo(conn); err != nil {
done <- err
return
}
}
done <- nil
}()
output, err := execute(context.Background(), listener.Addr().String(), "synthetic", "help")
if err != nil {
t.Fatal(err)
}
if output != "first packet\nsecond packet" {
t.Fatalf("output=%q", output)
}
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestSplitAuth(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
_ = client.SetDeadline(time.Now().Add(time.Second))
done := make(chan error, 1)
go func() {
defer server.Close()
var request rcon.Packet
if _, err := request.ReadFrom(server); err != nil {
done <- err
return
}
var reply bytes.Buffer
_, _ = rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, request.ID, "").WriteTo(&reply)
packet := reply.Bytes()
for _, part := range [][]byte{packet[:len(packet)-1], packet[len(packet)-1:]} {
if _, err := server.Write(part); err != nil {
done <- err
return
}
}
_, err := rcon.NewPacket(rcon.SERVERDATA_RESPONSE_VALUE, cmdID, "ok").WriteTo(server)
done <- err
}()
if err := authenticate(client, "synthetic-password"); err != nil {
t.Fatal(err)
}
var response rcon.Packet
if _, err := response.ReadFrom(client); err != nil {
t.Fatal(err)
}
if response.ID != cmdID || response.Body() != "ok" {
t.Fatalf("id=%d body=%q", response.ID, response.Body())
}
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestAuthFailure(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
_ = client.SetDeadline(time.Now().Add(time.Second))
go func() {
defer server.Close()
var request rcon.Packet
_, _ = request.ReadFrom(server)
_, _ = rcon.NewPacket(rcon.SERVERDATA_AUTH_RESPONSE, -1, "").WriteTo(server)
}()
if err := authenticate(client, "wrong-password"); !errors.Is(err, rcon.ErrAuthFailed) {
t.Fatalf("error=%v", err)
}
}