ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
6 changed files with 145336 additions and 0 deletions
66
packages/grafana-rcon/backend/auth.go
Normal file
66
packages/grafana-rcon/backend/auth.go
Normal 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
|
||||
}
|
||||
158
packages/grafana-rcon/backend/auth_test.go
Normal file
158
packages/grafana-rcon/backend/auth_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
140
packages/grafana-rcon/backend/main.go
Normal file
140
packages/grafana-rcon/backend/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
75
packages/grafana-rcon/backend/main_test.go
Normal file
75
packages/grafana-rcon/backend/main_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
97
packages/grafana-rcon/backend/rcon.go
Normal file
97
packages/grafana-rcon/backend/rcon.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
128
packages/grafana-rcon/backend/rcon_test.go
Normal file
128
packages/grafana-rcon/backend/rcon_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue