mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
302 lines
9.9 KiB
Go
302 lines
9.9 KiB
Go
//go:build unix
|
|
|
|
package control
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
)
|
|
|
|
// patience bounds a wait on a real goroutine. Nothing reaches it on the happy
|
|
// path; it is there so a hang fails as a test instead of as a timeout.
|
|
const patience = 5 * time.Second
|
|
|
|
// served starts a listener and an accept loop on a temporary socket.
|
|
//
|
|
// wait is idempotent: the test body and the cleanup behind it both call it, and
|
|
// a plain channel read would make the second caller wait forever for a value
|
|
// the first one already took.
|
|
func served(t *testing.T, registry *Registry) (client Client,
|
|
stop context.CancelFunc, wait func() error) {
|
|
t.Helper()
|
|
|
|
path := socketPathIn(t)
|
|
listener, err := Listen(path)
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- Serve(ctx, listener, registry) }()
|
|
|
|
wait = sync.OnceValue(func() error {
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-time.After(patience):
|
|
t.Error("Serve did not return after its context was cancelled")
|
|
return nil
|
|
}
|
|
})
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
wait()
|
|
})
|
|
return Client{Path: path}, cancel, wait
|
|
}
|
|
|
|
func TestARequestIsAnsweredOverTheSocket(t *testing.T) {
|
|
registry := NewRegistry()
|
|
registry.Register("version.get", func(context.Context, json.RawMessage) (any, error) {
|
|
return map[string]any{"version": "2.0.0rc1"}, nil
|
|
})
|
|
client, _, _ := served(t, registry)
|
|
|
|
raw, err := client.Call(t.Context(), "version.get", nil)
|
|
if err != nil {
|
|
t.Fatalf("call: %v", err)
|
|
}
|
|
var result struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if err := json.Unmarshal(raw, &result); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if result.Version != "2.0.0rc1" {
|
|
t.Errorf("version = %q", result.Version)
|
|
}
|
|
}
|
|
|
|
// A failure comes back with its code intact, so a caller branches on the same
|
|
// value it would have got from a local call.
|
|
func TestAFailureKeepsItsCodeAcrossTheSocket(t *testing.T) {
|
|
registry := NewRegistry()
|
|
registry.Register("config.apply", func(context.Context, json.RawMessage) (any, error) {
|
|
return nil, domain.Errorf(domain.CodeConflict, "配置已被别处修改")
|
|
})
|
|
client, _, _ := served(t, registry)
|
|
|
|
_, err := client.Call(t.Context(), "config.apply", map[string]any{"x": 1})
|
|
if err == nil {
|
|
t.Fatal("a failing handler reported success")
|
|
}
|
|
code, ok := domain.CodeOf(err)
|
|
if !ok || code != domain.CodeConflict {
|
|
t.Errorf("code = %s/%v, want Conflict", code, ok)
|
|
}
|
|
if err.Error() == "" {
|
|
t.Error("the failure carries no message")
|
|
}
|
|
}
|
|
|
|
// A name that is not in the catalogue is refused, and a name that is in it but
|
|
// unbound is refused differently. One means "you made a typo", the other means
|
|
// "this build does not have that yet", and a client can act on the difference.
|
|
func TestUnknownAndUnimplementedAreDistinctOverTheSocket(t *testing.T) {
|
|
client, _, _ := served(t, NewRegistry())
|
|
|
|
_, err := client.Call(t.Context(), "no.such.method", nil)
|
|
if code, _ := domain.CodeOf(err); code != domain.CodeNotFound {
|
|
t.Errorf("unknown method gave %s, want NotFound", code)
|
|
}
|
|
|
|
_, err = client.Call(t.Context(), "status.get", nil)
|
|
if code, _ := domain.CodeOf(err); code != domain.CodeUnsupportedCapability {
|
|
t.Errorf("unbound method gave %s, want UnsupportedCapability", code)
|
|
}
|
|
}
|
|
|
|
// T38 -- too many callers at once are told so rather than queued.
|
|
//
|
|
// The cap exists so a stuck or hostile local caller cannot open descriptors
|
|
// until the daemon runs out of them. Refusing with a reason matters as much as
|
|
// refusing: a caller that got a silent EOF cannot tell "too busy" from "the
|
|
// service died mid-request".
|
|
func TestTooManyConcurrentCallersAreRefusedWithAReason(t *testing.T) {
|
|
entered := make(chan struct{}, MaxConcurrentConnections+1)
|
|
release := make(chan struct{})
|
|
|
|
registry := NewRegistry()
|
|
// A catalogue name with a long budget: a read method would be cut off by
|
|
// the response deadline before the test could fill the slots.
|
|
registry.Register("presets.refresh", func(ctx context.Context, _ json.RawMessage) (any, error) {
|
|
entered <- struct{}{}
|
|
select {
|
|
case <-release:
|
|
case <-ctx.Done():
|
|
}
|
|
return map[string]any{"ok": true}, nil
|
|
})
|
|
client, _, _ := served(t, registry)
|
|
// Released once, whichever path gets there first: an early t.Fatal must not
|
|
// leave eight handlers blocked, and the happy path releases them itself.
|
|
releaseOnce := sync.OnceFunc(func() { close(release) })
|
|
defer releaseOnce()
|
|
|
|
var busy sync.WaitGroup
|
|
for range MaxConcurrentConnections {
|
|
busy.Go(func() {
|
|
_, _ = client.Call(context.Background(), "presets.refresh", nil)
|
|
})
|
|
}
|
|
// Every slot is occupied by a handler that is actually running, so the next
|
|
// caller cannot be served by chance.
|
|
for range MaxConcurrentConnections {
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(patience):
|
|
t.Fatal("the handlers never all started")
|
|
}
|
|
}
|
|
|
|
_, err := client.Call(t.Context(), "presets.refresh", nil)
|
|
if err == nil {
|
|
t.Fatal("a caller beyond the cap was served")
|
|
}
|
|
if code, _ := domain.CodeOf(err); code != domain.CodeBusy {
|
|
t.Errorf("code = %s, want Busy", code)
|
|
}
|
|
|
|
// The same refusal, by a caller whose request is too large to sit in the
|
|
// socket buffer. This is the version that does not depend on timing, and it
|
|
// is the one that found the defect: with a small request the caller's write
|
|
// usually lands before the refusal closes, so the answer survives and the
|
|
// check above passes -- most of the time, and less often on a loaded
|
|
// machine. With a request bigger than the buffer the write is still in
|
|
// flight when the close arrives, and a refusal that closed without reading
|
|
// reset the connection and destroyed the answer it had just written. The
|
|
// caller then saw a failed write, which is the silent-EOF outcome this path
|
|
// exists to prevent, and saw it only when the daemon really was busy.
|
|
//
|
|
// 400 KiB is comfortably past a Linux Unix-socket buffer (208 KiB) and
|
|
// still inside the 512 KiB request limit, so the client sends it rather
|
|
// than refusing it itself.
|
|
blob := strings.Repeat("x", 400*1024)
|
|
_, err = client.Call(t.Context(), "presets.refresh",
|
|
map[string]string{"blob": blob})
|
|
if err == nil {
|
|
t.Fatal("a large caller beyond the cap was served")
|
|
}
|
|
if code, _ := domain.CodeOf(err); code != domain.CodeBusy {
|
|
t.Errorf("large request refused with %s, want Busy", code)
|
|
}
|
|
|
|
releaseOnce()
|
|
busy.Wait()
|
|
// Reopened once the slots are free: the cap is backpressure, not a fuse.
|
|
if _, err := client.Call(t.Context(), "presets.refresh", nil); err != nil {
|
|
t.Errorf("a caller after the burst was refused too: %v", err)
|
|
}
|
|
}
|
|
|
|
// Stopping is a stop, not a failure: procd would log an ordinary SIGTERM as a
|
|
// crash if this returned its context's error.
|
|
func TestServeReturnsCleanlyWhenStopped(t *testing.T) {
|
|
_, stop, wait := served(t, NewRegistry())
|
|
stop()
|
|
if err := wait(); err != nil {
|
|
t.Errorf("a clean stop returned %v", err)
|
|
}
|
|
}
|
|
|
|
// Serve waits for its handlers. A stop that returned while one was still
|
|
// writing would leave the caller reading a socket nobody was tracking.
|
|
//
|
|
// The handler here ignores its cancellation and waits for the test instead,
|
|
// which is what makes the check decisive: if Serve did not wait, it would
|
|
// return immediately, and the only way to say so is to watch it not return
|
|
// while the handler is definitely still there.
|
|
func TestServeWaitsForItsHandlers(t *testing.T) {
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
releaseOnce := sync.OnceFunc(func() { close(release) })
|
|
defer releaseOnce()
|
|
|
|
registry := NewRegistry()
|
|
registry.Register("presets.refresh", func(context.Context, json.RawMessage) (any, error) {
|
|
close(entered)
|
|
<-release
|
|
return map[string]any{"ok": true}, nil
|
|
})
|
|
client, stop, wait := served(t, registry)
|
|
|
|
go func() { _, _ = client.Call(context.Background(), "presets.refresh", nil) }()
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(patience):
|
|
t.Fatal("the handler never started")
|
|
}
|
|
|
|
returned := make(chan error, 1)
|
|
go func() { returned <- wait() }()
|
|
|
|
stop()
|
|
select {
|
|
case <-returned:
|
|
t.Fatal("Serve returned while a handler was still running")
|
|
case <-time.After(200 * time.Millisecond):
|
|
// Still waiting, which is the point. Serve takes microseconds to
|
|
// return once it stops waiting, so this bound is not a race.
|
|
}
|
|
|
|
releaseOnce()
|
|
select {
|
|
case err := <-returned:
|
|
if err != nil {
|
|
t.Errorf("Serve returned %v after its handler finished", err)
|
|
}
|
|
case <-time.After(patience):
|
|
t.Fatal("Serve never returned after its handler finished")
|
|
}
|
|
}
|
|
|
|
// A connection whose caller cannot be identified is refused, not served.
|
|
//
|
|
// Failing closed is the only defensible answer: the socket carries commands
|
|
// that change a router's network configuration, and "probably fine" is not an
|
|
// identity check.
|
|
func TestAConnectionWithNoVerifiableCallerIsRefused(t *testing.T) {
|
|
// net.Pipe is an in-memory connection with no kernel credentials behind it,
|
|
// which is exactly the case the check has to refuse.
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
if err := verifyPeer(server); err == nil {
|
|
t.Fatal("a connection with no verifiable caller was accepted")
|
|
}
|
|
}
|
|
|
|
// T38 -- the caller's identity comes from the kernel.
|
|
//
|
|
// The 0700 directory already makes the socket unreachable for anybody else, so
|
|
// this is the second of two independent checks. It is worth having because the
|
|
// first depends on a mode an installer or a backup restore can change.
|
|
func TestOnlyRootAndTheDaemonsOwnUserAreAccepted(t *testing.T) {
|
|
if !allowedPeer(0) {
|
|
t.Error("root was refused")
|
|
}
|
|
if !allowedPeer(uint32(os.Getuid())) {
|
|
t.Error("the daemon's own user was refused")
|
|
}
|
|
|
|
// Some uid that is neither. 65534 is nobody on every distribution this
|
|
// runs on; if the tests happen to be running as it, use another.
|
|
stranger := uint32(65534)
|
|
if stranger == uint32(os.Getuid()) {
|
|
stranger = 12345
|
|
}
|
|
if allowedPeer(stranger) {
|
|
t.Errorf("uid %d was accepted; the socket is root-only", stranger)
|
|
}
|
|
}
|