Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2e109f9
merge: combine relay and enforced-network monitoring prerequisites
Baylem Oct 21, 2026
94d56a4
feat: expose bounded game performance and relay monitoring
Baylem Oct 21, 2026
0c5fdf5
fix(agent): bound NuclearOption request writes
Baylem Oct 21, 2026
6cd3f48
fix: align monitoring validation and relay regression fixtures
Baylem Oct 21, 2026
6b9681e
fix: include safe operator upgrades in monitoring installs
Baylem Oct 21, 2026
55ddd2d
fix(e2e): read metrics probes from completed pod logs
Baylem Oct 22, 2026
7b794f2
fix(e2e): distinguish policy verdicts from probe diagnostics
Baylem Oct 22, 2026
6923113
test(e2e): drive event streams and retry monitoring downloads
Baylem Oct 23, 2026
a072caf
fix(ci): integrate upstream selection and current Tailscale SDK
Baylem Oct 23, 2026
d31f279
chore: synchronize CRDs with workspace generator
Baylem Oct 23, 2026
3248d53
ci: use Go 1.27 compatible golangci-lint
Baylem Oct 23, 2026
dcc7161
test: synchronize closed RCON response fixtures
Baylem Oct 23, 2026
59a0f44
test: clarify closed response deadline synchronization
Baylem Oct 23, 2026
9522fff
ci: align lint tooling with Go 1.27 export data
Baylem Oct 23, 2026
9197f6c
docs: classify lint tool dependency versions
Baylem Oct 23, 2026
71cd384
fix(operator): use explicit waits for expected reconcile retries
Baylem Oct 23, 2026
5042ab1
fix(agent): confine mod deletion to an open directory root
Baylem Oct 23, 2026
d1ded7f
fix: align tailscale condition documentation and coverage
Baylem Oct 24, 2026
cfe8c19
fix: preserve known relay outages and reviewed prerequisites
Baylem Oct 25, 2026
18b4999
chore: merge upstream namespace controls into monitoring
Baylem Oct 25, 2026
4137e81
chore: reconcile monitoring branch with upstream
Baylem Oct 27, 2026
cb5e73b
test: exercise production relay observation and scoped wipe inventory
Baylem Oct 27, 2026
8a59abb
chore: reconcile relay monitoring with upstream forwarding
Baylem Oct 28, 2026
a80d3c2
Merge upstream FRP dependency security fix
Baylem Oct 31, 2026
42e047f
chore: stack monitoring on current network isolation
Baylem Nov 4, 2026
4701667
test(operator): seed finished backups before forget manager startup
Baylem Nov 5, 2026
843d3b0
merge: update network isolation base for observability
Baylem Nov 5, 2026
ea8de2b
Merge updated network isolation parent into observability
Baylem Nov 6, 2026
e564740
Merge network isolation workspace checksum into observability
Baylem Nov 6, 2026
38332c5
merge: carry shipped x/net security update into observability
Baylem Nov 6, 2026
6aa7651
merge: carry sentinel exposure cleanup and read regression fixes
Baylem Nov 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,8 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
- run: helm lint charts/gameplane
- name: evaluate monitoring rules
run: bash charts/gameplane/tests/monitoring-rules.sh

standalone-compose:
name: standalone Compose smoke
Expand Down Expand Up @@ -1257,10 +1259,10 @@ jobs:
run: python3 charts/tests/check_gateway.py
- name: standalone panel isolation and persistence chart renders
run: python3 charts/tests/check_standalone.py
- name: strict network profile and sentinel policy selectors
run: python3 charts/tests/check_network_isolation.py
- name: auxiliary resources and management placement chart renders
run: python3 charts/tests/check_workloads.py
- name: strict network profile and sentinel policy selectors
run: python3 charts/tests/check_network_isolation.py
- name: operator Secret namespace permissions and cache configuration
run: python3 charts/tests/check_operator_secrets.py
- name: sanity-check rendered manifests
Expand Down Expand Up @@ -1347,10 +1349,16 @@ jobs:
| grep -q 'app.kubernetes.io/name: gameplane-telemetry-receiver'
awk '/kind: Service/,/^---$/' /tmp/telemetry-receiver-scrape.yaml \
| grep -q 'name: dashboard'
# scrapeNamespaceSelector unset (the chart default): neither
# exception renders, matching the pre-fix behavior.
# Missing scrape ingress must fail with actionable guidance.
if helm template gameplane charts/gameplane --set serviceMonitors.enabled=true > /tmp/monitoring-invalid.txt 2>&1; then
echo "::error::monitoring without a scrape selector unexpectedly rendered"
exit 1
fi
grep -q 'serviceMonitors.scrapeNamespaceSelector' /tmp/monitoring-invalid.txt
# Explicit external policy ownership renders no chart exception.
helm template gameplane charts/gameplane \
--set serviceMonitors.enabled=true \
--set serviceMonitors.externallyManagedPolicy=true \
--show-only templates/networkpolicies.yaml > /tmp/netpol-default.yaml
if grep -q 'allow-prometheus-to-agent' /tmp/netpol-default.yaml; then
echo "::error::allow-prometheus-to-agent rendered without serviceMonitors.scrapeNamespaceSelector set"
Expand Down Expand Up @@ -1527,7 +1535,8 @@ jobs:
# keeps working without api.telemetry.enabled.
helm template gameplane charts/gameplane \
--set api.telemetry.receiver.enabled=true \
--set serviceMonitors.enabled=true > /tmp/rendered-reuse-values-receiver.yaml
--set serviceMonitors.enabled=true \
--set serviceMonitors.externallyManagedPolicy=true > /tmp/rendered-reuse-values-receiver.yaml
python3 - <<'PY'
import yaml
docs = [d for d in yaml.safe_load_all(open('/tmp/rendered-reuse-values-receiver.yaml')) if d]
Expand Down Expand Up @@ -1824,9 +1833,13 @@ jobs:
# (operator does zero logins and is wait-dominated, so it runs
# wide; the api buckets stay within the login limiter's budget).
# The arm64 job below uses the same steps and per-bucket settings.
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, network-isolation, management-placement]
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, management-placement, network-isolation, monitoring]
arch: [amd64]
include:
- bucket: monitoring
parallel: 1
test_timeout: 20m
job_timeout: 75
- bucket: network-isolation
parallel: 1
test_timeout: 15m
Expand Down Expand Up @@ -1896,9 +1909,12 @@ jobs:
- name: bring up cluster + helm install
if: env.RUN_REGEX != '' || env.TAIL_REGEX != ''
env:
GAMEPLANE_E2E_CNI: ${{ matrix.bucket == 'network-isolation' && 'cilium' || 'kindnet' }}
GAMEPLANE_E2E_PROFILE: ${{ matrix.bucket == 'management-placement' && 'management' || 'default' }}
GAMEPLANE_E2E_CNI: ${{ (matrix.bucket == 'network-isolation' || matrix.bucket == 'monitoring') && 'cilium' || 'kindnet' }}
run: ./deploy/kind/e2e.sh up "$CLUSTER" e2e "$GAMEPLANE_E2E_PROFILE"
- name: install real monitoring fixture
if: matrix.bucket == 'monitoring' && env.RUN_REGEX != ''
run: bash deploy/kind/monitoring.sh
- name: run go e2e (${{ matrix.bucket }})
if: env.RUN_REGEX != ''
run: |
Expand All @@ -1922,7 +1938,7 @@ jobs:
uses: ./.github/actions/dump-cluster-state
with:
context: ${{ env.CTX }}
namespaces: "gameplane-system,gameplane-games"
namespaces: "${{ matrix.bucket == 'monitoring' && 'gameplane-system,gameplane-games,monitoring' || 'gameplane-system,gameplane-games' }}"
include-previous-logs: "true"

e2e-go-arm64:
Expand Down Expand Up @@ -2711,7 +2727,7 @@ jobs:
'go-e2e-unit': (n) => n === 'go (test/e2e)',
'api-postgres': (n) => n === 'api (postgres)',
'e2e-buckets': (n) => n === 'e2e bucket coverage',
'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|network-isolation|management-placement) \//.test(n) && n.includes('/ amd64 '),
'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|management-placement|network-isolation|monitoring) \//.test(n) && n.includes('/ amd64 '),
'e2e-go-arm64': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components) \//.test(n) && n.includes('/ arm64 '),
'e2e-multicluster': (n) => n.startsWith('e2e multicluster /') && n.includes('/ amd64 '),
'e2e-multicluster-arm64': (n) => n.startsWith('e2e multicluster /') && n.includes('/ arm64 '),
Expand Down Expand Up @@ -2864,7 +2880,7 @@ jobs:
const bucketSet = new Set();
for (const j of apiJobs) {
const n = j.name || '';
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|network-isolation|management-placement) \//);
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|management-placement|network-isolation|monitoring) \//);
if (m) bucketSet.add(m[1]);
if (/^e2e multicluster \//.test(n)) bucketSet.add('multicluster');
if (/^e2e upgrade \//.test(n)) bucketSet.add('upgrade');
Expand Down
6 changes: 5 additions & 1 deletion agent/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"

"github.com/GameplanePanel/gameplane/agent/internal/actions"
"github.com/GameplanePanel/gameplane/agent/internal/auth"
Expand Down Expand Up @@ -178,6 +179,7 @@ func main() {
// and isn't meant to be trusted with the console/files/RCON routes that
// cert would also unlock.

statusSource := status.New(rconClient, statusSpec)
mountProtected := func(protected chi.Router) {

// Console/Logs open long-lived WebSockets and Mods installs can run
Expand All @@ -198,7 +200,7 @@ func main() {
quiesce.Mount(bounded, rconClient, gameName, quiesceSpec)
lifecycle.Mount(bounded, rconClient, gameName, lifecycleSpec)
actions.Mount(bounded, rconClient, gameName, actionSpecs)
status.Mount(bounded, rconClient, statusSpec)
statusSource.Mount(bounded)
})
}
r.Group(func(protected chi.Router) {
Expand Down Expand Up @@ -230,6 +232,8 @@ func main() {
// Prometheus metrics for per-server resource usage and player counts.
// Registered with the default registry so promhttp.Handler() picks it up.
m := metrics.New()
prometheus.MustRegister(statusSource)
go statusSource.Run(ctx)

// Resource usage: in proc mode (the operator shares the pod's PID
// namespace and sets GAMEPLANE_USAGE_PROC) the agent reports the game
Expand Down
18 changes: 13 additions & 5 deletions agent/internal/caps/caps.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,19 @@ type ModInstall struct {
// StatusMetric reads one live readout from an RCON command's output via a
// named-group regex (group "value").
type StatusMetric struct {
ID string `json:"id"`
DisplayName string `json:"displayName,omitempty"`
Command string `json:"command"`
Regex string `json:"regex"`
Unit string `json:"unit,omitempty"`
ID string `json:"id"`
DisplayName string `json:"displayName,omitempty"`
Command string `json:"command"`
Regex string `json:"regex"`
Unit string `json:"unit,omitempty"`
Performance *PerformanceMetric `json:"performance,omitempty"`
}

// PerformanceMetric mirrors the optional, explicitly numeric status contract.
type PerformanceMetric struct {
Signal string `json:"signal"`
Unit string `json:"unit"`
DegradedThreshold *string `json:"degradedThreshold,omitempty"`
}

// PlayerActions maps moderation actions to console command templates
Expand Down
28 changes: 16 additions & 12 deletions agent/internal/rcon/nuclearoption.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,21 @@ func (c *NuclearOption) Close() error {
// If the response has no body (body length = 0), an empty string is returned.
// Non-2000 status codes produce an error that includes the status code and,
// if present, the error detail from the response body.
// One deadline bounds the complete exchange, including dialing and request writes.
func (c *NuclearOption) Exec(cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()

d := c.execDeadline
if d <= 0 {
d = defaultNuclearOptionExecDeadline
}
exchangeDeadline := time.Now().Add(d)

// Ensure connection is open.
if c.conn == nil {
d := net.Dialer{Timeout: c.dialTimeout}
ctx, cancel := context.WithTimeout(context.Background(), c.dialTimeout)
ctx, cancel := context.WithDeadline(context.Background(), exchangeDeadline)
defer cancel()
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(c.host, fmt.Sprint(c.port)))
if err != nil {
Expand All @@ -144,6 +151,14 @@ func (c *NuclearOption) Exec(cmd string) (string, error) {
return "", fmt.Errorf("nuclearoption rcon: marshal request: %w", err)
}

// Bound writes as well as reads. Arm this before sending anything: a
// server that stops reading must not retain the shared client forever.
if err := c.conn.SetDeadline(exchangeDeadline); err != nil {
_ = c.conn.Close()
c.conn = nil
return "", fmt.Errorf("nuclearoption rcon: set deadline: %w", err)
}

// Write request: 4-byte LE length + body.
reqLen := len(reqBody)
if reqLen < 0 || reqLen > math.MaxUint32 {
Expand All @@ -162,17 +177,6 @@ func (c *NuclearOption) Exec(cmd string) (string, error) {
return "", fmt.Errorf("nuclearoption rcon: write body: %w", err)
}

// Set deadline for the entire response read.
d := c.execDeadline
if d <= 0 {
d = defaultNuclearOptionExecDeadline
}
if err := c.conn.SetDeadline(time.Now().Add(d)); err != nil {
_ = c.conn.Close()
c.conn = nil
return "", fmt.Errorf("nuclearoption rcon: set deadline: %w", err)
}

// Read response: 4-byte status + 4-byte body length + body.
statusBuf := make([]byte, 4)
if _, err := io.ReadFull(c.conn, statusBuf); err != nil {
Expand Down
46 changes: 46 additions & 0 deletions agent/internal/rcon/nuclearoption_deadline_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package rcon

import (
"io"
"net"
"strings"
"testing"
"time"
)

func TestNuclearOptionDeadlineIncludesRequestWrites(t *testing.T) {
t.Parallel()
for _, readHeader := range []bool{false, true} {
stage := "write length"
if readHeader {
stage = "write body"
}
t.Run(stage, func(t *testing.T) {
t.Parallel()
local, remote := net.Pipe()
defer local.Close()
defer remote.Close()
client := NewNuclearOption("ignored", 1, nil)
client.conn = local
client.execDeadline = 100 * time.Millisecond
if readHeader {
go func() { _, _ = io.CopyN(io.Discard, remote, 4) }()
}
done := make(chan error, 1)
go func() { _, err := client.Exec("get-server-id"); done <- err }()
select {
case err := <-done:
if err == nil || !isTimeout(err) || !strings.Contains(err.Error(), stage) {
t.Fatalf("stalled %s must time out: %v", stage, err)
}
case <-time.After(2 * time.Second):
_ = remote.Close()
<-done
t.Fatalf("stalled %s exceeded the command budget", stage)
}
if client.conn != nil {
t.Fatal("timed-out partial request must discard the connection")
}
})
}
}
2 changes: 1 addition & 1 deletion agent/internal/rcon/nuclearoption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ func TestNuclearOptionWriteBodyError(t *testing.T) {
}

// TestNuclearOptionSetDeadlineError verifies that a failure setting the
// response read deadline (after the request was fully written) is reported
// exchange deadline (before sending the request) is reported
// and the connection is reset.
func TestNuclearOptionSetDeadlineError(t *testing.T) {
local, remote := net.Pipe()
Expand Down
46 changes: 42 additions & 4 deletions agent/internal/rcon/rcon.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ const (
// byte length (F-104).
minPacketSize = 10
maxPacketSize = 4*4096 + 10 // 16394

// sourceMaxResponseBytes permits large administrative listings while
// bounding the aggregate allocation across otherwise valid fragments.
// Status collection applies its separate, smaller 64-KiB acceptance cap.
sourceMaxResponseBytes = 8 * 1024 * 1024
)

// PassFn resolves the RCON password on demand (allowing rotation without restart).
Expand Down Expand Up @@ -86,6 +91,8 @@ type Client struct {

// execDeadline bounds a single command exchange (see Exec).
execDeadline time.Duration
// authDeadline separately bounds authentication, including all preamble packets.
authDeadline time.Duration

// authFailureCooldown bounds how long ensureLocked refuses to re-dial
// after an explicit AUTH_RESPONSE rejection (id=-1). A struct field
Expand Down Expand Up @@ -114,6 +121,8 @@ type Client struct {
// (heartbeat, players, quiesce, console).
const defaultExecDeadline = 30 * time.Second

const defaultSourceAuthDeadline = 10 * time.Second

// defaultAuthFailureCooldown bounds how long ensureLocked backs off after
// an explicit auth rejection before it will dial again. Mirrors
// websocket.go's defaultWebSocketAuthFailureCooldown — same value, same
Expand All @@ -133,6 +142,7 @@ func New(host string, port int, pw PassFn) *Client {
addr: net.JoinHostPort(host, fmt.Sprint(port)),
password: pw,
execDeadline: defaultExecDeadline,
authDeadline: defaultSourceAuthDeadline,
authFailureCooldown: defaultAuthFailureCooldown,
}
}
Expand All @@ -154,7 +164,8 @@ func (c *Client) Exec(cmd string) (string, error) {
if d <= 0 {
d = defaultExecDeadline
}
_ = conn.SetDeadline(time.Now().Add(d))
exchangeDeadline := time.Now().Add(d)
_ = conn.SetDeadline(exchangeDeadline)
defer func() { _ = conn.SetDeadline(time.Time{}) }()

reqID := c.allocID()
Expand All @@ -172,12 +183,22 @@ func (c *Client) Exec(cmd string) (string, error) {
// reqID) and bound the wait for any trailing fragments with a short grace
// window: a single-packet reply returns as soon as the grace
// elapses or the server closes; a fragmented Source reply keeps extending
// the window as each fragment arrives.
// the window as each fragment arrives, never beyond exchangeDeadline.
var out bytes.Buffer
gotResponse := false
for {
if !time.Now().Before(exchangeDeadline) {
c.dropLocked()
return "", fmt.Errorf("rcon: command exchange deadline exceeded: %w", os.ErrDeadlineExceeded)
}
id, _, body, err := c.readPacket()
if err != nil {
if !time.Now().Before(exchangeDeadline) {
// Reaching the hard deadline is not evidence of a complete
// response. Discard its partial text and any queued fragments.
c.dropLocked()
return "", fmt.Errorf("rcon: command exchange deadline exceeded: %w", os.ErrDeadlineExceeded)
}
if gotResponse {
// We already hold the reply. A timeout means the grace window
// elapsed on a still-healthy socket — keep it for reuse. Any
Expand All @@ -196,9 +217,17 @@ func (c *Client) Exec(cmd string) (string, error) {
// Source/Minecraft echo the request id in every reply fragment; ignore
// anything else.
if id == reqID {
if len(body) > sourceMaxResponseBytes-out.Len() {
c.dropLocked()
return "", fmt.Errorf("rcon: response exceeded limit of %d bytes", sourceMaxResponseBytes)
}
out.WriteString(body)
gotResponse = true
_ = conn.SetReadDeadline(time.Now().Add(responseGrace))
readDeadline := time.Now().Add(responseGrace)
if readDeadline.After(exchangeDeadline) {
readDeadline = exchangeDeadline
}
_ = conn.SetReadDeadline(readDeadline)
}
}
}
Expand Down Expand Up @@ -247,7 +276,12 @@ func (c *Client) ensureLocked() error {
return err
}
c.conn = conn
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
authBudget := c.authDeadline
if authBudget <= 0 {
authBudget = defaultSourceAuthDeadline
}
authDeadline := time.Now().Add(authBudget)
_ = conn.SetDeadline(authDeadline)

authID := c.allocID()
if err := c.writePacket(authID, typeAuth, pw); err != nil {
Expand All @@ -256,6 +290,10 @@ func (c *Client) ensureLocked() error {
}
// Server MAY send an empty RESPONSE_VALUE before AUTH_RESPONSE.
for {
if !time.Now().Before(authDeadline) {
c.dropLocked()
return fmt.Errorf("rcon: authentication deadline exceeded: %w", os.ErrDeadlineExceeded)
}
id, kind, _, err := c.readPacket()
if err != nil {
c.dropLocked()
Expand Down
Loading
Loading