Merge branch 'upstream/master' into master and preserve domain extensions

This commit is contained in:
Bot
2026-08-31 02:46:42 +08:00
758 changed files with 89269 additions and 1366 deletions
+3
View File
@@ -0,0 +1,3 @@
package agentcompatcontract
const IOStreamCapabilityHeader = "X-Nezha-AgentCompat-IOStream-Capability"
+9
View File
@@ -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
View File
@@ -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
}
}
}
+21 -8
View File
@@ -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) {
+63 -7
View File
@@ -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())
}
})
}
}
+84 -8
View File
@@ -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)
}
}
+106
View File
@@ -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 ""
+103
View File
@@ -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)
}
+130
View File
@@ -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()
}
+142
View File
@@ -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
}
+152
View File
@@ -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)
}
}
+28 -7
View File
@@ -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
}
+232
View File
@@ -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
}