mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.
Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.
Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.
Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.
Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package grpcx
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/nezhahq/nezha/proto"
|
||||
@@ -16,8 +17,15 @@ type IOStream interface {
|
||||
Context() context.Context
|
||||
}
|
||||
|
||||
// IOStreamWrapper adapts a gRPC IOStream into an io.ReadWriteCloser and
|
||||
// serializes every Send on the underlying stream. grpc-go forbids concurrent
|
||||
// SendMsg on the same stream (Documentation/concurrency.md); the dashboard
|
||||
// runs an IOStream keepalive goroutine alongside MCP fs.transfer / terminal /
|
||||
// fm Writers, so all of them must funnel through this sendMu. The matching
|
||||
// agent-side fix is serialIOStreamSender in agent/cmd/agent/mcp_fs_transfer.go.
|
||||
type IOStreamWrapper struct {
|
||||
IOStream
|
||||
sendMu sync.Mutex
|
||||
dataBuf []byte
|
||||
closed *atomic.Bool
|
||||
closeCh chan struct{}
|
||||
@@ -31,21 +39,77 @@ func NewIOStreamWrapper(stream IOStream) *IOStreamWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
// Send writes a single IOStreamData frame under the wrapper's send mutex.
|
||||
// All goroutines that share this wrapper — keepalive ticker, Write callers,
|
||||
// and any direct frame writer — MUST go through Send (or SendKeepalive)
|
||||
// rather than touching the embedded IOStream.Send, otherwise grpc-go's
|
||||
// concurrent-SendMsg invariant is violated and frames can corrupt or panic.
|
||||
func (iw *IOStreamWrapper) Send(data *proto.IOStreamData) error {
|
||||
iw.sendMu.Lock()
|
||||
defer iw.sendMu.Unlock()
|
||||
return iw.IOStream.Send(data)
|
||||
}
|
||||
|
||||
// SendKeepalive sends the dashboard's empty-payload heartbeat through the
|
||||
// same sendMu as Send/Write so it cannot race the data path.
|
||||
func (iw *IOStreamWrapper) SendKeepalive() error {
|
||||
return iw.Send(&proto.IOStreamData{Data: []byte{}})
|
||||
}
|
||||
|
||||
// RecvFrame returns the next non-empty IOStream frame as a single contiguous
|
||||
// byte slice, preserving frame boundaries. Use this when a caller multiplexes
|
||||
// control frames (magic + payload) and data frames over the same stream and
|
||||
// must not let one frame's bytes spill into the next frame's parsing.
|
||||
//
|
||||
// The io.Reader path (Read) intentionally hides frame boundaries; callers that
|
||||
// need them — e.g. MCP fs.transfer download where NZTE may interrupt NZTD
|
||||
// payload mid-stream — call RecvFrame instead.
|
||||
func (iw *IOStreamWrapper) RecvFrame() ([]byte, error) {
|
||||
if len(iw.dataBuf) > 0 {
|
||||
out := iw.dataBuf
|
||||
iw.dataBuf = nil
|
||||
return out, nil
|
||||
}
|
||||
for {
|
||||
data, err := iw.Recv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
return data.Data, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (iw *IOStreamWrapper) Read(p []byte) (n int, err error) {
|
||||
if len(iw.dataBuf) > 0 {
|
||||
n := copy(p, iw.dataBuf)
|
||||
iw.dataBuf = iw.dataBuf[n:]
|
||||
return n, nil
|
||||
}
|
||||
var data *proto.IOStreamData
|
||||
if data, err = iw.Recv(); err != nil {
|
||||
return 0, err
|
||||
// Skip zero-length heartbeat frames sent by ioStreamKeepAlive (see
|
||||
// agent/cmd/agent/main.go ioStreamKeepAlive). protobuf treats an empty
|
||||
// `bytes` field as a default value but still ships a valid Message, so
|
||||
// Recv() returns a non-nil *IOStreamData whose Data is empty. Surfacing
|
||||
// that as (0, nil) is legal io.Reader behaviour but every caller in the
|
||||
// repo treats a 0-byte read as an unexpected control frame (e.g.
|
||||
// mcp_transfer.readXferFixedHeader returns "frame too short"). Loop here
|
||||
// until we get either real bytes or an error.
|
||||
for {
|
||||
var data *proto.IOStreamData
|
||||
if data, err = iw.Recv(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(data.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
n = copy(p, data.Data)
|
||||
if n < len(data.Data) {
|
||||
iw.dataBuf = data.Data[n:]
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
n = copy(p, data.Data)
|
||||
if n < len(data.Data) {
|
||||
iw.dataBuf = data.Data[n:]
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (iw *IOStreamWrapper) Write(p []byte) (n int, err error) {
|
||||
@@ -63,3 +127,12 @@ func (iw *IOStreamWrapper) Close() error {
|
||||
func (iw *IOStreamWrapper) Wait() {
|
||||
<-iw.closeCh
|
||||
}
|
||||
|
||||
// Done exposes the wrapper's close signal as a read-only channel so callers
|
||||
// that run alongside the wrapper (e.g. the dashboard's IOStream keepalive
|
||||
// goroutine) can cancel cooperatively. Without this they would only stop on
|
||||
// gRPC stream-context cancel or on their next failed Send, which can leave
|
||||
// a goroutine waiting up to one keepalive tick after the wrapper was closed.
|
||||
func (iw *IOStreamWrapper) Done() <-chan struct{} {
|
||||
return iw.closeCh
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package grpcx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/proto"
|
||||
)
|
||||
|
||||
// sendObservingStream records the maximum number of goroutines that are
|
||||
// inside Send at the same time. grpc-go's real server stream is NOT safe
|
||||
// under concurrent Send, but this fake never blocks so any concurrent
|
||||
// dispatch from the wrapper would surface here as maxInFlight > 1.
|
||||
type sendObservingStream struct {
|
||||
inFlight int32
|
||||
maxInFlight int32
|
||||
}
|
||||
|
||||
func (s *sendObservingStream) Recv() (*proto.IOStreamData, error) { return nil, nil }
|
||||
func (s *sendObservingStream) Context() context.Context { return context.Background() }
|
||||
func (s *sendObservingStream) Send(*proto.IOStreamData) error {
|
||||
cur := atomic.AddInt32(&s.inFlight, 1)
|
||||
defer atomic.AddInt32(&s.inFlight, -1)
|
||||
for {
|
||||
prev := atomic.LoadInt32(&s.maxInFlight)
|
||||
if cur <= prev || atomic.CompareAndSwapInt32(&s.maxInFlight, prev, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IOStreamWrapper.Send and SendKeepalive must be safe to call from many
|
||||
// goroutines concurrently — this is the dashboard-side dual of the agent's
|
||||
// serialIOStreamSender (see agent/cmd/agent/mcp_fs_transfer.go). Without the
|
||||
// wrapper's sendMu, dashboard IOStream keepalive + MCP fs.transfer Write
|
||||
// race the same gRPC stream, violating grpc-go's "no concurrent SendMsg"
|
||||
// contract. We pin that with a stress test: many goroutines hammer Send /
|
||||
// SendKeepalive / Write at once; the fake stream must NEVER observe more
|
||||
// than one in-flight Send.
|
||||
func TestIOStreamWrapper_SerializesConcurrentSends(t *testing.T) {
|
||||
obs := &sendObservingStream{}
|
||||
iw := NewIOStreamWrapper(obs)
|
||||
|
||||
const workers = 16
|
||||
const opsPerWorker = 200
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func(seed int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < opsPerWorker; j++ {
|
||||
switch (seed + j) % 3 {
|
||||
case 0:
|
||||
_ = iw.Send(&proto.IOStreamData{Data: []byte{byte(seed)}})
|
||||
case 1:
|
||||
_ = iw.SendKeepalive()
|
||||
case 2:
|
||||
_, _ = iw.Write([]byte{byte(j)})
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&obs.maxInFlight); got != 1 {
|
||||
t.Fatalf("IOStreamWrapper.Send must serialize through sendMu; observed max-in-flight=%d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package grpcx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nezhahq/nezha/proto"
|
||||
)
|
||||
|
||||
type fakeStream struct {
|
||||
frames []*proto.IOStreamData
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeStream) Recv() (*proto.IOStreamData, error) {
|
||||
if len(f.frames) == 0 {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
frame := f.frames[0]
|
||||
f.frames = f.frames[1:]
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
func (f *fakeStream) Send(*proto.IOStreamData) error { return nil }
|
||||
func (f *fakeStream) Context() context.Context { return context.Background() }
|
||||
|
||||
// Heartbeat frames sent by the agent (ioStreamKeepAlive in
|
||||
// agent/cmd/agent/main.go) carry an empty Data. The previous wrapper
|
||||
// surfaced them to callers as (n=0, nil), which made
|
||||
// mcp_transfer.readXferFixedHeader return "frame too short". This test
|
||||
// pins the contract: empty frames are transparently skipped and Read
|
||||
// only returns when it has either real bytes or an error.
|
||||
func TestIOStreamWrapper_ReadSkipsHeartbeats(t *testing.T) {
|
||||
stream := &fakeStream{
|
||||
frames: []*proto.IOStreamData{
|
||||
{Data: []byte{}},
|
||||
{Data: []byte{}},
|
||||
{Data: []byte("hello")},
|
||||
},
|
||||
}
|
||||
iw := NewIOStreamWrapper(stream)
|
||||
buf := make([]byte, 16)
|
||||
n, err := iw.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 5 || string(buf[:n]) != "hello" {
|
||||
t.Fatalf("expected 5 bytes 'hello', got n=%d data=%q", n, buf[:n])
|
||||
}
|
||||
}
|
||||
|
||||
// A stream that only ever sends heartbeats followed by an error must
|
||||
// surface the error rather than spin forever or hand the caller (0, nil).
|
||||
func TestIOStreamWrapper_ReadPropagatesErrorAfterHeartbeats(t *testing.T) {
|
||||
wantErr := errors.New("stream closed")
|
||||
stream := &fakeStream{
|
||||
frames: []*proto.IOStreamData{
|
||||
{Data: []byte{}},
|
||||
{Data: []byte{}},
|
||||
},
|
||||
err: wantErr,
|
||||
}
|
||||
iw := NewIOStreamWrapper(stream)
|
||||
buf := make([]byte, 8)
|
||||
n, err := iw.Read(buf)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error after heartbeats + Recv failure")
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected wrapped error %v, got %v", wantErr, err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("expected n=0 on error, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Close() must wake anything waiting on Done() immediately so co-running
|
||||
// goroutines (e.g. the dashboard's IOStream keepalive ticker) can exit
|
||||
// without waiting for the underlying gRPC stream context to cancel or for
|
||||
// their next Send to fail.
|
||||
func TestIOStreamWrapper_DoneFiresOnClose(t *testing.T) {
|
||||
iw := NewIOStreamWrapper(&fakeStream{})
|
||||
select {
|
||||
case <-iw.Done():
|
||||
t.Fatalf("Done() must not fire before Close()")
|
||||
default:
|
||||
}
|
||||
if err := iw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-iw.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("Done() did not fire after Close()")
|
||||
}
|
||||
// Idempotent: a second Close must not panic on the closed channel.
|
||||
if err := iw.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user