mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
Merge branch 'upstream/master' into master and preserve domain extensions
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
package agentcompatcontract
|
||||
|
||||
const IOStreamCapabilityHeader = "X-Nezha-AgentCompat-IOStream-Capability"
|
||||
@@ -0,0 +1,9 @@
|
||||
package agentcompatcontract
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIOStreamCapabilityHeaderUsesFrozenName(t *testing.T) {
|
||||
if IOStreamCapabilityHeader != "X-Nezha-AgentCompat-IOStream-Capability" {
|
||||
t.Fatalf("unexpected capability header name")
|
||||
}
|
||||
}
|
||||
+14
-5
@@ -54,16 +54,25 @@ func (provider *Provider) updateDomain(ctx context.Context, domain string) error
|
||||
return err
|
||||
}
|
||||
|
||||
// 当IPv4和IPv6同时成功才算作成功
|
||||
// 独立处理 IPv4 更新
|
||||
if *provider.DDNSProfile.EnableIPv4 {
|
||||
if err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr); err != nil {
|
||||
return err
|
||||
if provider.IPAddrs.IPv4Addr == "" {
|
||||
log.Printf("NEZHA>> Skip IPv4 update for domain %s: IPv4 address is empty", domain)
|
||||
} else {
|
||||
if err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 独立处理 IPv6 更新
|
||||
if *provider.DDNSProfile.EnableIPv6 {
|
||||
if err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr); err != nil {
|
||||
return err
|
||||
if provider.IPAddrs.IPv6Addr == "" {
|
||||
log.Printf("NEZHA>> Skip IPv6 update for domain %s: IPv6 address is empty", domain)
|
||||
} else {
|
||||
if err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -57,13 +58,19 @@ func (provider *Provider) SetRecords(ctx context.Context, zone string,
|
||||
provider.ipAddr = rr.Data
|
||||
provider.domain = fmt.Sprintf("%s.%s", rr.Name, strings.TrimSuffix(zone, "."))
|
||||
|
||||
req, err := provider.prepareRequest(ctx)
|
||||
// WebhookURL is attacker-controlled (GHSA-6x26-5727-rrm9); the request and
|
||||
// the client are paired so URL validation and DialContext pinning are driven
|
||||
// by a single DNS resolution. Do not swap the client for utils.HttpClient.
|
||||
req, client, err := provider.prepareRequest(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err)
|
||||
}
|
||||
if _, err := utils.HttpClient.Do(req); err != nil {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported record type: %T", rec)
|
||||
}
|
||||
@@ -72,26 +79,32 @@ func (provider *Provider) SetRecords(ctx context.Context, zone string,
|
||||
return recs, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, error) {
|
||||
func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, *http.Client, error) {
|
||||
u, err := provider.reqUrl()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
// Single SSRF check + dial pin; the returned client must be used by callers
|
||||
// so the dialer's pinned IP and the validated URL stay in sync.
|
||||
client, err := utils.NewRestrictedHTTPClient(u.String(), false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
body, err := provider.reqBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
headers, err := utils.GjsonIter(
|
||||
provider.formatWebhookString(provider.DDNSProfile.WebhookHeaders))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, requestTypes[provider.DDNSProfile.WebhookMethod], u.String(), strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
provider.setContentType(req)
|
||||
@@ -100,7 +113,7 @@ func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, er
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
return req, nil
|
||||
return req, client, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) setContentType(req *http.Request) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/model"
|
||||
@@ -44,7 +45,7 @@ func execCase(t *testing.T, item testSt) {
|
||||
t.Fatalf("Expected %s, but got %s", item.expectBody, reqBody)
|
||||
}
|
||||
|
||||
req, err := pw.prepareRequest(context.Background())
|
||||
req, _, err := pw.prepareRequest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %s", err)
|
||||
}
|
||||
@@ -69,11 +70,11 @@ func TestWebhookRequest(t *testing.T) {
|
||||
Domains: []string{"www.example.com"},
|
||||
MaxRetries: 1,
|
||||
EnableIPv4: &ipv4,
|
||||
WebhookURL: "http://ddns.example.com/?ip=#ip#",
|
||||
WebhookURL: "http://1.1.1.1/?ip=#ip#",
|
||||
WebhookMethod: methodGET,
|
||||
WebhookHeaders: `{"ip":"#ip#","record":"#record#"}`,
|
||||
},
|
||||
expectURL: "http://ddns.example.com/?ip=1.1.1.1",
|
||||
expectURL: "http://1.1.1.1/?ip=1.1.1.1",
|
||||
expectContentType: "",
|
||||
expectHeader: map[string]string{
|
||||
"ip": "1.1.1.1",
|
||||
@@ -85,12 +86,12 @@ func TestWebhookRequest(t *testing.T) {
|
||||
Domains: []string{"www.example.com"},
|
||||
MaxRetries: 1,
|
||||
EnableIPv4: &ipv4,
|
||||
WebhookURL: "http://ddns.example.com/api",
|
||||
WebhookURL: "http://1.1.1.1/api",
|
||||
WebhookMethod: methodPOST,
|
||||
WebhookRequestType: requestTypeJSON,
|
||||
WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`,
|
||||
},
|
||||
expectURL: "http://ddns.example.com/api",
|
||||
expectURL: "http://1.1.1.1/api",
|
||||
expectContentType: reqTypeJSON,
|
||||
expectBody: `{"ip":"1.1.1.1","record":"A"}`,
|
||||
},
|
||||
@@ -99,12 +100,12 @@ func TestWebhookRequest(t *testing.T) {
|
||||
Domains: []string{"www.example.com"},
|
||||
MaxRetries: 1,
|
||||
EnableIPv4: &ipv4,
|
||||
WebhookURL: "http://ddns.example.com/api",
|
||||
WebhookURL: "http://1.1.1.1/api",
|
||||
WebhookMethod: methodPOST,
|
||||
WebhookRequestType: requestTypeForm,
|
||||
WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`,
|
||||
},
|
||||
expectURL: "http://ddns.example.com/api",
|
||||
expectURL: "http://1.1.1.1/api",
|
||||
expectContentType: reqTypeForm,
|
||||
expectBody: "ip=1.1.1.1&record=A",
|
||||
},
|
||||
@@ -114,3 +115,58 @@ func TestWebhookRequest(t *testing.T) {
|
||||
execCase(t, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookTargetRejectsBlockedRanges(t *testing.T) {
|
||||
cases := []string{
|
||||
"http://0.0.0.0/",
|
||||
"http://10.1.2.3/",
|
||||
"http://100.64.0.1/",
|
||||
"http://127.0.0.1/",
|
||||
"http://127.255.255.254/",
|
||||
"http://169.254.169.254/",
|
||||
"http://172.16.0.1/",
|
||||
"http://192.0.0.1/",
|
||||
"http://192.0.2.1/",
|
||||
"http://192.168.1.1/",
|
||||
"http://198.18.0.1/",
|
||||
"http://198.51.100.1/",
|
||||
"http://203.0.113.1/",
|
||||
"http://224.0.0.1/",
|
||||
"http://240.0.0.1/",
|
||||
"http://[::]/",
|
||||
"http://[::1]/",
|
||||
"http://[::ffff:127.0.0.1]/",
|
||||
"http://[64:ff9b::1]/",
|
||||
"http://[100::1]/",
|
||||
"http://[2001:db8::1]/",
|
||||
"http://[fc00::1]/",
|
||||
"http://[fe80::1]/",
|
||||
"http://[ff00::1]/",
|
||||
"ftp://example.com/",
|
||||
"file:///etc/passwd",
|
||||
"http:///path",
|
||||
}
|
||||
|
||||
for _, rawURL := range cases {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
provider := Provider{DDNSProfile: &model.DDNSProfile{
|
||||
Domains: []string{"www.example.com"},
|
||||
WebhookURL: rawURL,
|
||||
WebhookMethod: methodGET,
|
||||
WebhookHeaders: `{}`,
|
||||
}}
|
||||
provider.ipAddr = "1.1.1.1"
|
||||
provider.domain = provider.DDNSProfile.Domains[0]
|
||||
provider.ipType = "ipv4"
|
||||
provider.recordType = "A"
|
||||
|
||||
_, _, err := provider.prepareRequest(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("expected %s to be rejected", rawURL)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not allowed") {
|
||||
t.Fatalf("expected not allowed error, got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -56,6 +120,9 @@ func (iw *IOStreamWrapper) Write(p []byte) (n int, err error) {
|
||||
func (iw *IOStreamWrapper) Close() error {
|
||||
if iw.closed.CompareAndSwap(false, true) {
|
||||
close(iw.closeCh)
|
||||
if closer, ok := iw.IOStream.(interface{ Close() error }); ok {
|
||||
return closer.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -63,3 +130,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)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,323 @@
|
||||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-30 21:58+0800\n"
|
||||
"PO-Revision-Date: 2026-08-12 05:39+0000\n"
|
||||
"Last-Translator: ace-consultoria <erec.novais@aceconsultoria.org>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://hosted.weblate.org/projects/"
|
||||
"nezha/nezha-dashboard/pt_BR/>\n"
|
||||
"Language: pt_BR\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n > 1;\n"
|
||||
"X-Generator: Weblate 2026.9.dev0\n"
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:104
|
||||
#, c-format
|
||||
msgid "alert id %d does not exist"
|
||||
msgstr "alerta id %d não existe"
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:108
|
||||
#: cmd/dashboard/controller/alertrule.go:156
|
||||
#: cmd/dashboard/controller/alertrule.go:176
|
||||
#: cmd/dashboard/controller/controller.go:226
|
||||
#: cmd/dashboard/controller/cron.go:58 cmd/dashboard/controller/cron.go:124
|
||||
#: cmd/dashboard/controller/cron.go:136 cmd/dashboard/controller/cron.go:195
|
||||
#: cmd/dashboard/controller/cron.go:224 cmd/dashboard/controller/ddns.go:131
|
||||
#: cmd/dashboard/controller/ddns.go:192 cmd/dashboard/controller/fm.go:43
|
||||
#: cmd/dashboard/controller/nat.go:59 cmd/dashboard/controller/nat.go:111
|
||||
#: cmd/dashboard/controller/nat.go:122 cmd/dashboard/controller/nat.go:162
|
||||
#: cmd/dashboard/controller/notification.go:112
|
||||
#: cmd/dashboard/controller/notification.go:166
|
||||
#: cmd/dashboard/controller/notification_group.go:76
|
||||
#: cmd/dashboard/controller/notification_group.go:152
|
||||
#: cmd/dashboard/controller/notification_group.go:164
|
||||
#: cmd/dashboard/controller/notification_group.go:233
|
||||
#: cmd/dashboard/controller/server.go:66 cmd/dashboard/controller/server.go:78
|
||||
#: cmd/dashboard/controller/server.go:137
|
||||
#: cmd/dashboard/controller/server.go:201
|
||||
#: cmd/dashboard/controller/server_group.go:75
|
||||
#: cmd/dashboard/controller/server_group.go:150
|
||||
#: cmd/dashboard/controller/server_group.go:229
|
||||
#: cmd/dashboard/controller/service.go:271
|
||||
#: cmd/dashboard/controller/service.go:342
|
||||
#: cmd/dashboard/controller/service.go:369
|
||||
#: cmd/dashboard/controller/terminal.go:41
|
||||
msgid "permission denied"
|
||||
msgstr "permissão negada"
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:184
|
||||
msgid "duration need to be at least 3"
|
||||
msgstr "duração precisa ser pelo menos 3"
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:188
|
||||
msgid "cycle_interval need to be at least 1"
|
||||
msgstr "cycle_interval precisa ser pelo menos 1"
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:191
|
||||
msgid "cycle_start is not set"
|
||||
msgstr "cycle_start não está definido"
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:194
|
||||
msgid "cycle_start is a future value"
|
||||
msgstr "cycle_start é um valor futuro"
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:199
|
||||
msgid "need to configure at least a single rule"
|
||||
msgstr "necessário configurar pelo menos uma única regra"
|
||||
|
||||
#: cmd/dashboard/controller/controller.go:220
|
||||
#: cmd/dashboard/controller/oauth2.go:153
|
||||
#: cmd/dashboard/controller/server_group.go:162
|
||||
#: cmd/dashboard/controller/service.go:97 cmd/dashboard/controller/user.go:27
|
||||
#: cmd/dashboard/controller/user.go:63
|
||||
msgid "unauthorized"
|
||||
msgstr "não autorizado"
|
||||
|
||||
#: cmd/dashboard/controller/controller.go:243
|
||||
msgid "database error"
|
||||
msgstr "erro de banco de dados"
|
||||
|
||||
#: cmd/dashboard/controller/cron.go:75 cmd/dashboard/controller/cron.go:149
|
||||
msgid "scheduled tasks cannot be triggered by alarms"
|
||||
msgstr "tarefas agendadas não podem ser desencadeadas por alarmes"
|
||||
|
||||
#: cmd/dashboard/controller/cron.go:132 cmd/dashboard/controller/cron.go:190
|
||||
#, c-format
|
||||
msgid "task id %d does not exist"
|
||||
msgstr "a tarefa id %d não existe"
|
||||
|
||||
#: cmd/dashboard/controller/ddns.go:57 cmd/dashboard/controller/ddns.go:122
|
||||
msgid "the retry count must be an integer between 1 and 10"
|
||||
msgstr "a contagem de repetições deve ser um inteiro entre 1 e 10"
|
||||
|
||||
#: cmd/dashboard/controller/ddns.go:81 cmd/dashboard/controller/ddns.go:154
|
||||
#, fuzzy
|
||||
msgid "error parsing %s: %v"
|
||||
msgstr "erro ao analisar %s: %v"
|
||||
|
||||
#: cmd/dashboard/controller/ddns.go:127 cmd/dashboard/controller/nat.go:118
|
||||
#, c-format
|
||||
msgid "profile id %d does not exist"
|
||||
msgstr "perfil id %d não existe"
|
||||
|
||||
#: cmd/dashboard/controller/fm.go:39 cmd/dashboard/controller/terminal.go:37
|
||||
msgid "server not found or not connected"
|
||||
msgstr "servidor não encontrado ou não conectado"
|
||||
|
||||
#: cmd/dashboard/controller/notification.go:69
|
||||
#: cmd/dashboard/controller/notification.go:131
|
||||
msgid "a test message"
|
||||
msgstr "uma mensagem de teste"
|
||||
|
||||
#: cmd/dashboard/controller/notification.go:108
|
||||
#, c-format
|
||||
msgid "notification id %d does not exist"
|
||||
msgstr "notificação id %d não existe"
|
||||
|
||||
#: cmd/dashboard/controller/notification_group.go:94
|
||||
#: cmd/dashboard/controller/notification_group.go:175
|
||||
msgid "have invalid notification id"
|
||||
msgstr "há um id de notificação inválido"
|
||||
|
||||
#: cmd/dashboard/controller/notification_group.go:160
|
||||
#: cmd/dashboard/controller/server_group.go:158
|
||||
#, c-format
|
||||
msgid "group id %d does not exist"
|
||||
msgstr "grupo id %d não existe"
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:42 cmd/dashboard/controller/oauth2.go:83
|
||||
msgid "provider is required"
|
||||
msgstr "o provedor é necessário"
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:52 cmd/dashboard/controller/oauth2.go:87
|
||||
#: cmd/dashboard/controller/oauth2.go:132
|
||||
msgid "provider not found"
|
||||
msgstr "provedor não encontrado"
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:100
|
||||
msgid "operation not permitted"
|
||||
msgstr "operação não permitida"
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:138
|
||||
msgid "code is required"
|
||||
msgstr "o código é necessário"
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:175
|
||||
msgid "oauth2 user not binded yet"
|
||||
msgstr "usuário oauth2 ainda não está vinculado"
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:217
|
||||
#: cmd/dashboard/controller/oauth2.go:223
|
||||
#: cmd/dashboard/controller/oauth2.go:228
|
||||
msgid "invalid state key"
|
||||
msgstr "chave de estado inválida"
|
||||
|
||||
#: cmd/dashboard/controller/server.go:74
|
||||
#, c-format
|
||||
msgid "server id %d does not exist"
|
||||
msgstr "servidor id %d não existe"
|
||||
|
||||
#: cmd/dashboard/controller/server.go:250
|
||||
#, fuzzy
|
||||
msgid "operation timeout"
|
||||
msgstr "tempo de espera esgotado"
|
||||
|
||||
#: cmd/dashboard/controller/server.go:257
|
||||
msgid "get server config failed: %v"
|
||||
msgstr "falha em obter a configuração do servidor: %v"
|
||||
|
||||
#: cmd/dashboard/controller/server.go:261
|
||||
msgid "get server config failed"
|
||||
msgstr "falha em obter a configuração do servidor"
|
||||
|
||||
#: cmd/dashboard/controller/server_group.go:92
|
||||
#: cmd/dashboard/controller/server_group.go:172
|
||||
msgid "have invalid server id"
|
||||
msgstr "há um id de servidor inválido"
|
||||
|
||||
#: cmd/dashboard/controller/service.go:90
|
||||
#: cmd/dashboard/controller/service.go:165
|
||||
msgid "server not found"
|
||||
msgstr "servidor não encontrado"
|
||||
|
||||
#: cmd/dashboard/controller/service.go:267
|
||||
#, c-format
|
||||
msgid "service id %d does not exist"
|
||||
msgstr "serviço id %d não existe"
|
||||
|
||||
#: cmd/dashboard/controller/user.go:68
|
||||
msgid "incorrect password"
|
||||
msgstr "senha incorreta"
|
||||
|
||||
#: cmd/dashboard/controller/user.go:82
|
||||
msgid "you don't have any oauth2 bindings"
|
||||
msgstr "você não tem nenhuma ligação oauth2 vinculada"
|
||||
|
||||
#: cmd/dashboard/controller/user.go:131
|
||||
msgid "password length must be greater than 6"
|
||||
msgstr "comprimento da senha deve ser maior que 6"
|
||||
|
||||
#: cmd/dashboard/controller/user.go:134
|
||||
msgid "username can't be empty"
|
||||
msgstr "nome de usuário não pode estar vazio"
|
||||
|
||||
#: cmd/dashboard/controller/user.go:137
|
||||
msgid "invalid role"
|
||||
msgstr "função inválida"
|
||||
|
||||
#: cmd/dashboard/controller/user.go:176
|
||||
#, fuzzy
|
||||
msgid "can't delete yourself"
|
||||
msgstr "você não pode se apagar"
|
||||
|
||||
#: service/rpc/io_stream.go:128
|
||||
msgid "timeout: no connection established"
|
||||
msgstr "tempo esgotado: nenhuma conexão estabelecida"
|
||||
|
||||
#: service/rpc/io_stream.go:131
|
||||
msgid "timeout: user connection not established"
|
||||
msgstr "tempo esgotado: conexão de usuário não estabelecida"
|
||||
|
||||
#: service/rpc/io_stream.go:134
|
||||
msgid "timeout: agent connection not established"
|
||||
msgstr "tempo esgotado: conexão de agente não estabelecida"
|
||||
|
||||
#: service/rpc/nezha.go:71
|
||||
msgid "Scheduled Task Executed Successfully"
|
||||
msgstr "Tarefa agendada Executada com sucesso"
|
||||
|
||||
#: service/rpc/nezha.go:75
|
||||
msgid "Scheduled Task Executed Failed"
|
||||
msgstr "Falha ao executar tarefa agendada"
|
||||
|
||||
#: service/rpc/nezha.go:274
|
||||
msgid "IP Changed"
|
||||
msgstr "IP alterado"
|
||||
|
||||
#: service/singleton/alertsentinel.go:169
|
||||
msgid "Incident"
|
||||
msgstr "Incidente"
|
||||
|
||||
#: service/singleton/alertsentinel.go:179
|
||||
msgid "Resolved"
|
||||
msgstr "Resolvido"
|
||||
|
||||
#: service/singleton/crontask.go:54
|
||||
msgid "Tasks failed to register: ["
|
||||
msgstr "As tarefas não foram registradas: ["
|
||||
|
||||
#: service/singleton/crontask.go:61
|
||||
msgid ""
|
||||
"] These tasks will not execute properly. Fix them in the admin dashboard."
|
||||
msgstr ""
|
||||
"] Essas tarefas não serão executadas corretamente. Conserte as no painel de "
|
||||
"administração."
|
||||
|
||||
#: service/singleton/crontask.go:144 service/singleton/crontask.go:169
|
||||
#, c-format
|
||||
msgid "[Task failed] %s: server %s is offline and cannot execute the task"
|
||||
msgstr ""
|
||||
"[Tarefa falhou] %s: servidor %s está offline e não pode executar a tarefa"
|
||||
|
||||
#: service/singleton/servicesentinel.go:468
|
||||
#, c-format
|
||||
msgid "[Latency] %s %2f > %2f, Reporter: %s"
|
||||
msgstr "[Latência] %s %2f > %2f, Reportado por: %s"
|
||||
|
||||
#: service/singleton/servicesentinel.go:475
|
||||
#, c-format
|
||||
msgid "[Latency] %s %2f < %2f, Reporter: %s"
|
||||
msgstr "[Latência] %s %2f < %2f, Reportado por: %s"
|
||||
|
||||
#: service/singleton/servicesentinel.go:501
|
||||
#, c-format
|
||||
msgid "[%s] %s Reporter: %s, Error: %s"
|
||||
msgstr "[%s] %s Reportado por: %s, Erro: %s"
|
||||
|
||||
#: service/singleton/servicesentinel.go:544
|
||||
#, c-format
|
||||
msgid "[TLS] Fetch cert info failed, Reporter: %s, Error: %s"
|
||||
msgstr ""
|
||||
"[TLS] Erro ao obter informações do certificado, Reportado por: %s, Erro: %s"
|
||||
|
||||
#: service/singleton/servicesentinel.go:584
|
||||
#, c-format
|
||||
msgid "The TLS certificate will expire within seven days. Expiration time: %s"
|
||||
msgstr "O certificado TLS expirará dentro de sete dias. Tempo de expiração: %s"
|
||||
|
||||
#: service/singleton/servicesentinel.go:597
|
||||
#, c-format
|
||||
msgid ""
|
||||
"TLS certificate changed, old: issuer %s, expires at %s; new: issuer %s, "
|
||||
"expires at %s"
|
||||
msgstr ""
|
||||
"Certificado TLS foi alterado, antigo: emissor %s, expira em %s; novo: "
|
||||
"emissor %s, expira em %s"
|
||||
|
||||
#: service/singleton/servicesentinel.go:633
|
||||
msgid "No Data"
|
||||
msgstr "Sem dados"
|
||||
|
||||
#: service/singleton/servicesentinel.go:635
|
||||
msgid "Good"
|
||||
msgstr "Bom"
|
||||
|
||||
#: service/singleton/servicesentinel.go:637
|
||||
msgid "Low Availability"
|
||||
msgstr "Baixa disponibilidade"
|
||||
|
||||
#: service/singleton/servicesentinel.go:639
|
||||
msgid "Down"
|
||||
msgstr "Down"
|
||||
|
||||
#: service/singleton/user.go:60
|
||||
msgid "user id not specified"
|
||||
msgstr "id do usuário não especificado"
|
||||
Binary file not shown.
@@ -0,0 +1,313 @@
|
||||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-30 21:58+0800\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
"Language: ro\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < "
|
||||
"20)) ? 1 : 2;\n"
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:104
|
||||
#, c-format
|
||||
msgid "alert id %d does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:108
|
||||
#: cmd/dashboard/controller/alertrule.go:156
|
||||
#: cmd/dashboard/controller/alertrule.go:176
|
||||
#: cmd/dashboard/controller/controller.go:226
|
||||
#: cmd/dashboard/controller/cron.go:58 cmd/dashboard/controller/cron.go:124
|
||||
#: cmd/dashboard/controller/cron.go:136 cmd/dashboard/controller/cron.go:195
|
||||
#: cmd/dashboard/controller/cron.go:224 cmd/dashboard/controller/ddns.go:131
|
||||
#: cmd/dashboard/controller/ddns.go:192 cmd/dashboard/controller/fm.go:43
|
||||
#: cmd/dashboard/controller/nat.go:59 cmd/dashboard/controller/nat.go:111
|
||||
#: cmd/dashboard/controller/nat.go:122 cmd/dashboard/controller/nat.go:162
|
||||
#: cmd/dashboard/controller/notification.go:112
|
||||
#: cmd/dashboard/controller/notification.go:166
|
||||
#: cmd/dashboard/controller/notification_group.go:76
|
||||
#: cmd/dashboard/controller/notification_group.go:152
|
||||
#: cmd/dashboard/controller/notification_group.go:164
|
||||
#: cmd/dashboard/controller/notification_group.go:233
|
||||
#: cmd/dashboard/controller/server.go:66 cmd/dashboard/controller/server.go:78
|
||||
#: cmd/dashboard/controller/server.go:137
|
||||
#: cmd/dashboard/controller/server.go:201
|
||||
#: cmd/dashboard/controller/server_group.go:75
|
||||
#: cmd/dashboard/controller/server_group.go:150
|
||||
#: cmd/dashboard/controller/server_group.go:229
|
||||
#: cmd/dashboard/controller/service.go:271
|
||||
#: cmd/dashboard/controller/service.go:342
|
||||
#: cmd/dashboard/controller/service.go:369
|
||||
#: cmd/dashboard/controller/terminal.go:41
|
||||
msgid "permission denied"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:184
|
||||
msgid "duration need to be at least 3"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:188
|
||||
msgid "cycle_interval need to be at least 1"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:191
|
||||
msgid "cycle_start is not set"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:194
|
||||
msgid "cycle_start is a future value"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/alertrule.go:199
|
||||
msgid "need to configure at least a single rule"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/controller.go:220
|
||||
#: cmd/dashboard/controller/oauth2.go:153
|
||||
#: cmd/dashboard/controller/server_group.go:162
|
||||
#: cmd/dashboard/controller/service.go:97 cmd/dashboard/controller/user.go:27
|
||||
#: cmd/dashboard/controller/user.go:63
|
||||
msgid "unauthorized"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/controller.go:243
|
||||
msgid "database error"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/cron.go:75 cmd/dashboard/controller/cron.go:149
|
||||
msgid "scheduled tasks cannot be triggered by alarms"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/cron.go:132 cmd/dashboard/controller/cron.go:190
|
||||
#, c-format
|
||||
msgid "task id %d does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/ddns.go:57 cmd/dashboard/controller/ddns.go:122
|
||||
msgid "the retry count must be an integer between 1 and 10"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/ddns.go:81 cmd/dashboard/controller/ddns.go:154
|
||||
msgid "error parsing %s: %v"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/ddns.go:127 cmd/dashboard/controller/nat.go:118
|
||||
#, c-format
|
||||
msgid "profile id %d does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/fm.go:39 cmd/dashboard/controller/terminal.go:37
|
||||
msgid "server not found or not connected"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/notification.go:69
|
||||
#: cmd/dashboard/controller/notification.go:131
|
||||
msgid "a test message"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/notification.go:108
|
||||
#, c-format
|
||||
msgid "notification id %d does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/notification_group.go:94
|
||||
#: cmd/dashboard/controller/notification_group.go:175
|
||||
msgid "have invalid notification id"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/notification_group.go:160
|
||||
#: cmd/dashboard/controller/server_group.go:158
|
||||
#, c-format
|
||||
msgid "group id %d does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:42 cmd/dashboard/controller/oauth2.go:83
|
||||
msgid "provider is required"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:52 cmd/dashboard/controller/oauth2.go:87
|
||||
#: cmd/dashboard/controller/oauth2.go:132
|
||||
msgid "provider not found"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:100
|
||||
msgid "operation not permitted"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:138
|
||||
msgid "code is required"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:175
|
||||
msgid "oauth2 user not binded yet"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/oauth2.go:217
|
||||
#: cmd/dashboard/controller/oauth2.go:223
|
||||
#: cmd/dashboard/controller/oauth2.go:228
|
||||
msgid "invalid state key"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/server.go:74
|
||||
#, c-format
|
||||
msgid "server id %d does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/server.go:250
|
||||
msgid "operation timeout"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/server.go:257
|
||||
msgid "get server config failed: %v"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/server.go:261
|
||||
msgid "get server config failed"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/server_group.go:92
|
||||
#: cmd/dashboard/controller/server_group.go:172
|
||||
msgid "have invalid server id"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/service.go:90
|
||||
#: cmd/dashboard/controller/service.go:165
|
||||
msgid "server not found"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/service.go:267
|
||||
#, c-format
|
||||
msgid "service id %d does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/user.go:68
|
||||
msgid "incorrect password"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/user.go:82
|
||||
msgid "you don't have any oauth2 bindings"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/user.go:131
|
||||
msgid "password length must be greater than 6"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/user.go:134
|
||||
msgid "username can't be empty"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/user.go:137
|
||||
msgid "invalid role"
|
||||
msgstr ""
|
||||
|
||||
#: cmd/dashboard/controller/user.go:176
|
||||
msgid "can't delete yourself"
|
||||
msgstr ""
|
||||
|
||||
#: service/rpc/io_stream.go:128
|
||||
msgid "timeout: no connection established"
|
||||
msgstr ""
|
||||
|
||||
#: service/rpc/io_stream.go:131
|
||||
msgid "timeout: user connection not established"
|
||||
msgstr ""
|
||||
|
||||
#: service/rpc/io_stream.go:134
|
||||
msgid "timeout: agent connection not established"
|
||||
msgstr ""
|
||||
|
||||
#: service/rpc/nezha.go:71
|
||||
msgid "Scheduled Task Executed Successfully"
|
||||
msgstr ""
|
||||
|
||||
#: service/rpc/nezha.go:75
|
||||
msgid "Scheduled Task Executed Failed"
|
||||
msgstr ""
|
||||
|
||||
#: service/rpc/nezha.go:274
|
||||
msgid "IP Changed"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/alertsentinel.go:169
|
||||
msgid "Incident"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/alertsentinel.go:179
|
||||
msgid "Resolved"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/crontask.go:54
|
||||
msgid "Tasks failed to register: ["
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/crontask.go:61
|
||||
msgid ""
|
||||
"] These tasks will not execute properly. Fix them in the admin dashboard."
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/crontask.go:144 service/singleton/crontask.go:169
|
||||
#, c-format
|
||||
msgid "[Task failed] %s: server %s is offline and cannot execute the task"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:468
|
||||
#, c-format
|
||||
msgid "[Latency] %s %2f > %2f, Reporter: %s"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:475
|
||||
#, c-format
|
||||
msgid "[Latency] %s %2f < %2f, Reporter: %s"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:501
|
||||
#, c-format
|
||||
msgid "[%s] %s Reporter: %s, Error: %s"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:544
|
||||
#, c-format
|
||||
msgid "[TLS] Fetch cert info failed, Reporter: %s, Error: %s"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:584
|
||||
#, c-format
|
||||
msgid "The TLS certificate will expire within seven days. Expiration time: %s"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:597
|
||||
#, c-format
|
||||
msgid ""
|
||||
"TLS certificate changed, old: issuer %s, expires at %s; new: issuer %s, "
|
||||
"expires at %s"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:633
|
||||
msgid "No Data"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:635
|
||||
msgid "Good"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:637
|
||||
msgid "Low Availability"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/servicesentinel.go:639
|
||||
msgid "Down"
|
||||
msgstr ""
|
||||
|
||||
#: service/singleton/user.go:60
|
||||
msgid "user id not specified"
|
||||
msgstr ""
|
||||
@@ -0,0 +1,103 @@
|
||||
package idcodec
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/sqids/sqids-go"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
const (
|
||||
baseAlphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
hkdfInfo = "nezha/idcodec/alphabet/v1"
|
||||
minLength = 8
|
||||
minMasterKey = 32
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotInitialized = errors.New("idcodec: not initialized")
|
||||
ErrInvalidCode = errors.New("idcodec: invalid id code")
|
||||
ErrMasterKeyShort = errors.New("idcodec: master key too short")
|
||||
|
||||
mu sync.RWMutex
|
||||
encoder *sqids.Sqids
|
||||
)
|
||||
|
||||
func Init(masterKey []byte) error {
|
||||
if len(masterKey) < minMasterKey {
|
||||
return ErrMasterKeyShort
|
||||
}
|
||||
alphaKey := make([]byte, 32)
|
||||
if _, err := io.ReadFull(hkdf.New(sha256.New, masterKey, nil, []byte(hkdfInfo)), alphaKey); err != nil {
|
||||
return err
|
||||
}
|
||||
enc, err := sqids.New(sqids.Options{
|
||||
Alphabet: keyedShuffle(baseAlphabet, alphaKey),
|
||||
MinLength: minLength,
|
||||
Blocklist: []string{},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mu.Lock()
|
||||
encoder = enc
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func Encode(id uint64) (string, error) {
|
||||
mu.RLock()
|
||||
enc := encoder
|
||||
mu.RUnlock()
|
||||
if enc == nil {
|
||||
return "", ErrNotInitialized
|
||||
}
|
||||
return enc.Encode([]uint64{id})
|
||||
}
|
||||
|
||||
func Decode(code string) (uint64, error) {
|
||||
mu.RLock()
|
||||
enc := encoder
|
||||
mu.RUnlock()
|
||||
if enc == nil {
|
||||
return 0, ErrNotInitialized
|
||||
}
|
||||
nums := enc.Decode(code)
|
||||
if len(nums) != 1 {
|
||||
return 0, ErrInvalidCode
|
||||
}
|
||||
if got, err := enc.Encode(nums); err != nil || got != code {
|
||||
return 0, ErrInvalidCode
|
||||
}
|
||||
return nums[0], nil
|
||||
}
|
||||
|
||||
func keyedShuffle(alphabet string, key []byte) string {
|
||||
runes := []rune(alphabet)
|
||||
mac := hmac.New(sha256.New, key)
|
||||
var counter uint64
|
||||
var pool []byte
|
||||
next := func() byte {
|
||||
if len(pool) == 0 {
|
||||
buf := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(buf, counter)
|
||||
counter++
|
||||
mac.Reset()
|
||||
mac.Write(buf)
|
||||
pool = mac.Sum(nil)
|
||||
}
|
||||
b := pool[0]
|
||||
pool = pool[1:]
|
||||
return b
|
||||
}
|
||||
for i := len(runes) - 1; i > 0; i-- {
|
||||
j := int(next()) % (i + 1)
|
||||
runes[i], runes[j] = runes[j], runes[i]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package idcodec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testMasterKey = "this-is-a-32-byte-master-key-ok!"
|
||||
|
||||
func resetEncoder(t *testing.T) {
|
||||
t.Helper()
|
||||
mu.Lock()
|
||||
encoder = nil
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||
resetEncoder(t)
|
||||
if err := Init([]byte(testMasterKey)); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
cases := []uint64{1, 2, 42, 1_000_000, 1<<63 - 1}
|
||||
for _, id := range cases {
|
||||
code, err := Encode(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode(%d): %v", id, err)
|
||||
}
|
||||
if len(code) < minLength {
|
||||
t.Fatalf("code %q shorter than min %d", code, minLength)
|
||||
}
|
||||
got, err := Decode(code)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(%q): %v", code, err)
|
||||
}
|
||||
if got != id {
|
||||
t.Fatalf("round-trip mismatch: got %d, want %d", got, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeBeforeInit(t *testing.T) {
|
||||
resetEncoder(t)
|
||||
if _, err := Encode(1); err != ErrNotInitialized {
|
||||
t.Fatalf("Encode without Init: want ErrNotInitialized, got %v", err)
|
||||
}
|
||||
if _, err := Decode("abcdefgh"); err != ErrNotInitialized {
|
||||
t.Fatalf("Decode without Init: want ErrNotInitialized, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRejectsShortMasterKey(t *testing.T) {
|
||||
resetEncoder(t)
|
||||
if err := Init([]byte("too-short")); err != ErrMasterKeyShort {
|
||||
t.Fatalf("Init short master key: want ErrMasterKeyShort, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeInvalidInputs(t *testing.T) {
|
||||
resetEncoder(t)
|
||||
if err := Init([]byte(testMasterKey)); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
for _, code := range []string{"", "@@@@", strings.Repeat("!", 16)} {
|
||||
if _, err := Decode(code); err == nil {
|
||||
t.Fatalf("Decode(%q) must fail", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlphabetChangesWithMasterKey(t *testing.T) {
|
||||
resetEncoder(t)
|
||||
if err := Init([]byte(testMasterKey)); err != nil {
|
||||
t.Fatalf("Init A: %v", err)
|
||||
}
|
||||
codeA, err := Encode(42)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode A: %v", err)
|
||||
}
|
||||
|
||||
resetEncoder(t)
|
||||
if err := Init([]byte(testMasterKey + "rotated-suffix-makes-key-longer!")); err != nil {
|
||||
t.Fatalf("Init B: %v", err)
|
||||
}
|
||||
codeB, err := Encode(42)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode B: %v", err)
|
||||
}
|
||||
if codeA == codeB {
|
||||
t.Fatalf("rotating master key must change hashid encoding for the same id; both produced %q", codeA)
|
||||
}
|
||||
|
||||
if _, err := Decode(codeA); err == nil {
|
||||
t.Fatalf("after rotation, old hashid %q must not decode under new key", codeA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentEncodeDecodeIsSafe(t *testing.T) {
|
||||
resetEncoder(t)
|
||||
if err := Init([]byte(testMasterKey)); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func(seed uint64) {
|
||||
defer wg.Done()
|
||||
for j := uint64(0); j < 1000; j++ {
|
||||
id := seed*1000 + j
|
||||
code, err := Encode(id)
|
||||
if err != nil {
|
||||
t.Errorf("Encode(%d): %v", id, err)
|
||||
return
|
||||
}
|
||||
got, err := Decode(code)
|
||||
if err != nil {
|
||||
t.Errorf("Decode(%q): %v", code, err)
|
||||
return
|
||||
}
|
||||
if got != id {
|
||||
t.Errorf("round-trip: got %d, want %d", got, id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(uint64(i))
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -1,16 +1,55 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HttpClient / HttpClientSkipTlsVerify must not be used to dispatch
|
||||
// requests to user-controlled URLs (SSRF risk, GHSA-6x26-5727-rrm9).
|
||||
// For any attacker-controlled URL use NewRestrictedHTTPClient instead.
|
||||
var (
|
||||
HttpClientSkipTlsVerify *http.Client
|
||||
HttpClient *http.Client
|
||||
)
|
||||
|
||||
var ErrHTTPURLTargetNotAllowed = errors.New("HTTP URL target is not allowed")
|
||||
|
||||
var blockedHTTPClientCIDRs = mustParseHTTPClientCIDRs([]string{
|
||||
"0.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"192.0.0.0/24",
|
||||
"192.0.2.0/24",
|
||||
"192.168.0.0/16",
|
||||
"198.18.0.0/15",
|
||||
"198.51.100.0/24",
|
||||
"203.0.113.0/24",
|
||||
"224.0.0.0/4",
|
||||
"240.0.0.0/4",
|
||||
"::/128",
|
||||
"::1/128",
|
||||
"::ffff:0:0/96",
|
||||
"64:ff9b::/96",
|
||||
"64:ff9b:1::/48",
|
||||
"100::/64",
|
||||
"2001::/23",
|
||||
"2001:db8::/32",
|
||||
"2002::/16",
|
||||
"fc00::/7",
|
||||
"fe80::/10",
|
||||
"ff00::/8",
|
||||
})
|
||||
|
||||
func init() {
|
||||
HttpClientSkipTlsVerify = httpClient(_httpClient{
|
||||
Transport: httpTransport(_httpTransport{
|
||||
@@ -47,3 +86,106 @@ func httpClient(conf _httpClient) *http.Client {
|
||||
Timeout: time.Minute * 10,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRestrictedHTTPClient(rawURL string, skipVerifyTLS bool) (*http.Client, error) {
|
||||
parsedURL, ip, err := ResolveAllowedHTTPURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildRestrictedHTTPClient(parsedURL, ip, skipVerifyTLS), nil
|
||||
}
|
||||
|
||||
// buildRestrictedHTTPClient assembles a client whose DialContext is pinned to
|
||||
// the already-vetted IP. Separated from NewRestrictedHTTPClient so tests can
|
||||
// exercise the SNI / redirect behavior without relying on live DNS.
|
||||
func buildRestrictedHTTPClient(parsedURL *url.URL, ip net.IP, skipVerifyTLS bool) *http.Client {
|
||||
port := parsedURL.Port()
|
||||
if port == "" {
|
||||
if parsedURL.Scheme == "https" {
|
||||
port = "443"
|
||||
} else {
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
// Pin outbound webhooks to the vetted IP so DNS changes cannot retarget private hosts.
|
||||
targetAddress := net.JoinHostPort(ip.String(), port)
|
||||
dialer := &net.Dialer{}
|
||||
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, targetAddress)
|
||||
},
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerifyTLS, ServerName: parsedURL.Hostname()},
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Timeout: time.Minute * 10,
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveAllowedHTTPURL(rawURL string) (*url.URL, net.IP, error) {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return nil, nil, ErrHTTPURLTargetNotAllowed
|
||||
}
|
||||
|
||||
host := parsedURL.Hostname()
|
||||
if host == "" {
|
||||
return nil, nil, ErrHTTPURLTargetNotAllowed
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if !HTTPURLTargetIPAllowed(ip) {
|
||||
return nil, nil, ErrHTTPURLTargetNotAllowed
|
||||
}
|
||||
return parsedURL, ip, nil
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, nil, ErrHTTPURLTargetNotAllowed
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !HTTPURLTargetIPAllowed(ip) {
|
||||
return nil, nil, ErrHTTPURLTargetNotAllowed
|
||||
}
|
||||
}
|
||||
|
||||
return parsedURL, ips[0], nil
|
||||
}
|
||||
|
||||
func HTTPURLTargetIPAllowed(ip net.IP) bool {
|
||||
parsedIP, ok := netipFromIP(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, cidr := range blockedHTTPClientCIDRs {
|
||||
if cidr.Contains(parsedIP) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return parsedIP.IsGlobalUnicast()
|
||||
}
|
||||
|
||||
func netipFromIP(ip net.IP) (netip.Addr, bool) {
|
||||
parsedIP, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return parsedIP.Unmap(), true
|
||||
}
|
||||
|
||||
func mustParseHTTPClientCIDRs(cidrs []string) []netip.Prefix {
|
||||
prefixes := make([]netip.Prefix, 0, len(cidrs))
|
||||
for _, cidr := range cidrs {
|
||||
prefixes = append(prefixes, netip.MustParsePrefix(cidr))
|
||||
}
|
||||
return prefixes
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHTTPURLTargetIPAllowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
allowed bool
|
||||
}{
|
||||
{name: "public IPv4", address: "1.1.1.1", allowed: true},
|
||||
{name: "public IPv6", address: "2606:4700:4700::1111", allowed: true},
|
||||
{name: "well-known NAT64", address: "64:ff9b::a9fe:a9fe", allowed: false},
|
||||
{name: "local-use NAT64", address: "64:ff9b:1::a9fe:a9fe", allowed: false},
|
||||
{name: "6to4 public IPv4 embedding", address: "2002:0101:0101::1", allowed: false},
|
||||
{name: "6to4 link-local IPv4 embedding", address: "2002:a9fe:a9fe::1", allowed: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ip := net.ParseIP(test.address)
|
||||
if ip == nil {
|
||||
t.Fatalf("ParseIP(%q) returned nil", test.address)
|
||||
}
|
||||
if got := HTTPURLTargetIPAllowed(ip); got != test.allowed {
|
||||
t.Fatalf("HTTPURLTargetIPAllowed(%q) = %t, want %t", test.address, got, test.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllowedHTTPURLRejectsSpecialIPv6Literals(t *testing.T) {
|
||||
for _, rawURL := range []string{
|
||||
"http://[64:ff9b:1::a9fe:a9fe]/metadata",
|
||||
"http://[2002:a9fe:a9fe::1]/metadata",
|
||||
} {
|
||||
t.Run(rawURL, func(t *testing.T) {
|
||||
_, _, err := ResolveAllowedHTTPURL(rawURL)
|
||||
if !errors.Is(err, ErrHTTPURLTargetNotAllowed) {
|
||||
t.Fatalf("ResolveAllowedHTTPURL(%q) error = %v, want %v", rawURL, err, ErrHTTPURLTargetNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRestrictedHTTPClientPreservesHostnameAsTLSServerName(t *testing.T) {
|
||||
// Construct a hostname URL paired with an arbitrary public IP so we exercise
|
||||
// the SNI preservation path without depending on live DNS in unit tests.
|
||||
parsed, err := url.Parse("https://example.com/webhook")
|
||||
if err != nil {
|
||||
t.Fatalf("parse url: %v", err)
|
||||
}
|
||||
pinnedIP := net.ParseIP("1.1.1.1")
|
||||
if pinnedIP == nil {
|
||||
t.Fatalf("expected valid pinned IP")
|
||||
}
|
||||
|
||||
client := buildRestrictedHTTPClient(parsed, pinnedIP, false)
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", client.Transport)
|
||||
}
|
||||
if transport.TLSClientConfig == nil {
|
||||
t.Fatalf("expected TLSClientConfig to be set")
|
||||
}
|
||||
// SNI must come from the original URL hostname so the certificate validates
|
||||
// the intended hostname, not the pinned dial IP.
|
||||
if got := transport.TLSClientConfig.ServerName; got != "example.com" {
|
||||
t.Fatalf("expected ServerName example.com, got %q", got)
|
||||
}
|
||||
if transport.TLSClientConfig.ServerName == pinnedIP.String() {
|
||||
t.Fatalf("ServerName must not be the pinned IP, got %q", transport.TLSClientConfig.ServerName)
|
||||
}
|
||||
if transport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatalf("expected verifyTLS path (InsecureSkipVerify=false)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRestrictedHTTPClientHonorsSkipVerifyTLS(t *testing.T) {
|
||||
parsed, _ := url.Parse("https://example.com/webhook")
|
||||
client := buildRestrictedHTTPClient(parsed, net.ParseIP("1.1.1.1"), true)
|
||||
transport := client.Transport.(*http.Transport)
|
||||
if !transport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatalf("expected InsecureSkipVerify=true when skipVerifyTLS=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRestrictedHTTPClientRejectsRedirects(t *testing.T) {
|
||||
parsed, _ := url.Parse("https://example.com/start")
|
||||
client := buildRestrictedHTTPClient(parsed, net.ParseIP("1.1.1.1"), false)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com/start", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
if err := client.CheckRedirect(req, []*http.Request{req}); err != http.ErrUseLastResponse {
|
||||
t.Fatalf("expected ErrUseLastResponse, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRestrictedHTTPClientPinsDialToVettedIP confirms DialContext routes
|
||||
// to the pinned IP even when the request URL uses a different hostname,
|
||||
// preventing DNS rebinding from retargeting traffic.
|
||||
func TestBuildRestrictedHTTPClientPinsDialToVettedIP(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
_, port, err := net.SplitHostPort(listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("split host port: %v", err)
|
||||
}
|
||||
|
||||
accepted := make(chan string, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
accepted <- ""
|
||||
return
|
||||
}
|
||||
accepted <- conn.LocalAddr().String()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
requestURL := "http://example.com:" + port + "/"
|
||||
parsed, _ := url.Parse(requestURL)
|
||||
pinned := net.ParseIP("127.0.0.1")
|
||||
client := buildRestrictedHTTPClient(parsed, pinned, false)
|
||||
client.Timeout = 2 * time.Second
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||
resp, _ := client.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
select {
|
||||
case addr := <-accepted:
|
||||
if addr == "" {
|
||||
t.Fatalf("listener accept failed")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("expected dial to reach pinned IP 127.0.0.1:%s, listener did not accept", port)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var _ io.ReadWriteCloser = (*RequestWrapper)(nil)
|
||||
@@ -14,6 +15,11 @@ type RequestWrapper struct {
|
||||
req *http.Request
|
||||
reader *bytes.Buffer
|
||||
writer net.Conn
|
||||
|
||||
closeOnce sync.Once
|
||||
closeInit sync.Once
|
||||
closeDone chan struct{}
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func NewRequestWrapper(req *http.Request, writer http.ResponseWriter) (*RequestWrapper, error) {
|
||||
@@ -27,12 +33,17 @@ func NewRequestWrapper(req *http.Request, writer http.ResponseWriter) (*RequestW
|
||||
}
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err = req.Write(buf); err != nil {
|
||||
return nil, err
|
||||
var bodyErr error
|
||||
if req.Body != nil {
|
||||
bodyErr = req.Body.Close()
|
||||
}
|
||||
return nil, errors.Join(err, bodyErr, conn.Close())
|
||||
}
|
||||
return &RequestWrapper{
|
||||
req: req,
|
||||
reader: buf,
|
||||
writer: conn,
|
||||
req: req,
|
||||
reader: buf,
|
||||
writer: conn,
|
||||
closeDone: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -53,7 +64,17 @@ func (rw *RequestWrapper) Write(p []byte) (int, error) {
|
||||
}
|
||||
|
||||
func (rw *RequestWrapper) Close() error {
|
||||
rw.req.Body.Close()
|
||||
rw.writer.Close()
|
||||
return nil
|
||||
rw.closeInit.Do(func() {
|
||||
rw.closeDone = make(chan struct{})
|
||||
})
|
||||
rw.closeOnce.Do(func() {
|
||||
var bodyErr error
|
||||
if rw.req.Body != nil {
|
||||
bodyErr = rw.req.Body.Close()
|
||||
}
|
||||
rw.closeErr = errors.Join(bodyErr, rw.writer.Close())
|
||||
close(rw.closeDone)
|
||||
})
|
||||
<-rw.closeDone
|
||||
return rw.closeErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
errRequestWrite = errors.New("request write failed")
|
||||
errBodyClose = errors.New("body close failed")
|
||||
errConnClose = errors.New("connection close failed")
|
||||
)
|
||||
|
||||
func TestNewRequestWrapper_closesHijackedConnectionWhenRequestWriteFails(t *testing.T) {
|
||||
// Given
|
||||
conn := newRequestWrapperTestConn(errConnClose)
|
||||
req := &http.Request{
|
||||
Method: "POST",
|
||||
URL: &url.URL{Scheme: "http", Host: "example.test", Path: "/nat"},
|
||||
Body: &requestWrapperTestBody{readErr: errRequestWrite, closeErr: errBodyClose},
|
||||
ContentLength: 1,
|
||||
}
|
||||
writer := &requestWrapperTestResponseWriter{conn: conn}
|
||||
|
||||
// When
|
||||
_, err := NewRequestWrapper(req, writer)
|
||||
|
||||
// Then
|
||||
if err == nil || !strings.Contains(err.Error(), errRequestWrite.Error()) {
|
||||
t.Fatalf("expected request write error, got %v", err)
|
||||
}
|
||||
if !errors.Is(err, errBodyClose) {
|
||||
t.Fatalf("expected body close error, got %v", err)
|
||||
}
|
||||
if !errors.Is(err, errConnClose) {
|
||||
t.Fatalf("expected connection close error, got %v", err)
|
||||
}
|
||||
if got := conn.closeCount.Load(); got != 1 {
|
||||
t.Fatalf("expected one connection close, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWrapper_Close_joinsBodyAndConnectionErrors(t *testing.T) {
|
||||
// Given
|
||||
body := &requestWrapperTestBody{closeErr: errBodyClose}
|
||||
conn := newRequestWrapperTestConn(errConnClose)
|
||||
rw := &RequestWrapper{
|
||||
req: &http.Request{Body: body},
|
||||
reader: bytes.NewBuffer(nil),
|
||||
writer: conn,
|
||||
closeDone: make(chan struct{}),
|
||||
}
|
||||
|
||||
// When
|
||||
err := rw.Close()
|
||||
|
||||
// Then
|
||||
if !errors.Is(err, errBodyClose) {
|
||||
t.Fatalf("expected body close error, got %v", err)
|
||||
}
|
||||
if !errors.Is(err, errConnClose) {
|
||||
t.Fatalf("expected connection close error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWrapper_Close_repeatedCallersReceiveRetainedErrorAndCloseOnce(t *testing.T) {
|
||||
// Given
|
||||
body := &requestWrapperTestBody{closeErr: errBodyClose}
|
||||
conn := newRequestWrapperTestConn(errConnClose)
|
||||
rw := &RequestWrapper{
|
||||
req: &http.Request{Body: body},
|
||||
reader: bytes.NewBuffer(nil),
|
||||
writer: conn,
|
||||
closeDone: make(chan struct{}),
|
||||
}
|
||||
|
||||
// When
|
||||
firstErr := rw.Close()
|
||||
secondErr := rw.Close()
|
||||
|
||||
// Then
|
||||
if firstErr != secondErr {
|
||||
t.Fatalf("expected identical retained error, got distinct values %p and %p", firstErr, secondErr)
|
||||
}
|
||||
if got := body.closeCount.Load(); got != 1 {
|
||||
t.Fatalf("expected one body close, got %d", got)
|
||||
}
|
||||
if got := conn.closeCount.Load(); got != 1 {
|
||||
t.Fatalf("expected one connection close, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWrapper_Close_concurrentCallersWaitForCopyToUnblock(t *testing.T) {
|
||||
// Given
|
||||
body := &requestWrapperTestBody{closeErr: errBodyClose}
|
||||
conn := newRequestWrapperTestConn(errConnClose)
|
||||
rw := &RequestWrapper{
|
||||
req: &http.Request{Body: body},
|
||||
reader: bytes.NewBuffer(nil),
|
||||
writer: conn,
|
||||
closeDone: make(chan struct{}),
|
||||
}
|
||||
readDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := rw.Read(make([]byte, 1))
|
||||
readDone <- err
|
||||
}()
|
||||
<-conn.readStarted
|
||||
|
||||
// When
|
||||
const callerCount = 8
|
||||
results := make(chan error, callerCount)
|
||||
var callers sync.WaitGroup
|
||||
callers.Add(callerCount)
|
||||
for range callerCount {
|
||||
go func() {
|
||||
defer callers.Done()
|
||||
results <- rw.Close()
|
||||
}()
|
||||
}
|
||||
callers.Wait()
|
||||
close(results)
|
||||
|
||||
// Then
|
||||
var retainedErr error
|
||||
for err := range results {
|
||||
if !errors.Is(err, errConnClose) {
|
||||
t.Fatalf("expected retained connection close error, got %v", err)
|
||||
}
|
||||
if !errors.Is(err, errBodyClose) {
|
||||
t.Fatalf("expected retained body close error, got %v", err)
|
||||
}
|
||||
if retainedErr == nil {
|
||||
retainedErr = err
|
||||
continue
|
||||
}
|
||||
if err != retainedErr {
|
||||
t.Fatalf("expected identical retained error, got distinct values %p and %p", retainedErr, err)
|
||||
}
|
||||
}
|
||||
<-readDone
|
||||
if got := body.closeCount.Load(); got != 1 {
|
||||
t.Fatalf("expected one body close, got %d", got)
|
||||
}
|
||||
if got := conn.closeCount.Load(); got != 1 {
|
||||
t.Fatalf("expected one connection close, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type requestWrapperTestBody struct {
|
||||
readErr error
|
||||
closeErr error
|
||||
closeCount atomic.Int32
|
||||
}
|
||||
|
||||
func (b *requestWrapperTestBody) Read([]byte) (int, error) {
|
||||
if b.readErr != nil {
|
||||
return 0, b.readErr
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (b *requestWrapperTestBody) Close() error {
|
||||
b.closeCount.Add(1)
|
||||
return b.closeErr
|
||||
}
|
||||
|
||||
type requestWrapperTestConn struct {
|
||||
net.Conn
|
||||
closeErr error
|
||||
closeCount atomic.Int32
|
||||
closeOnce sync.Once
|
||||
readStarted chan struct{}
|
||||
readDone chan struct{}
|
||||
}
|
||||
|
||||
func newRequestWrapperTestConn(closeErr error) *requestWrapperTestConn {
|
||||
return &requestWrapperTestConn{
|
||||
closeErr: closeErr,
|
||||
readStarted: make(chan struct{}),
|
||||
readDone: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *requestWrapperTestConn) Read([]byte) (int, error) {
|
||||
select {
|
||||
case <-c.readStarted:
|
||||
default:
|
||||
close(c.readStarted)
|
||||
}
|
||||
<-c.readDone
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
func (c *requestWrapperTestConn) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (c *requestWrapperTestConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
c.closeCount.Add(1)
|
||||
close(c.readDone)
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
|
||||
type requestWrapperTestResponseWriter struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func (w *requestWrapperTestResponseWriter) Header() http.Header {
|
||||
return make(http.Header)
|
||||
}
|
||||
|
||||
func (w *requestWrapperTestResponseWriter) Write([]byte) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (w *requestWrapperTestResponseWriter) WriteHeader(int) {}
|
||||
|
||||
func (w *requestWrapperTestResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return w.conn, bufio.NewReadWriter(bufio.NewReader(bytes.NewReader(nil)), bufio.NewWriter(io.Discard)), nil
|
||||
}
|
||||
Reference in New Issue
Block a user