snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
12
internal/tools/ascii.go
Normal file
12
internal/tools/ascii.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package tools
|
||||
|
||||
import "unicode"
|
||||
|
||||
func IsASCII(s string) bool {
|
||||
for _, c := range s {
|
||||
if c > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
35
internal/tools/ascii_test.go
Normal file
35
internal/tools/ascii_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_IsASCII(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ascii string",
|
||||
input: "hello",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-ascii string",
|
||||
input: "こんにちは",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if IsASCII(tt.input) != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, !tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
41
internal/tools/code_translate.go
Normal file
41
internal/tools/code_translate.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
)
|
||||
|
||||
func ConnectErrorToToHTTPResponse(err error, transforms []configtypes.ConnectCodeToHTTPResponseTransform) (configtypes.TransformedConnectErrorHttpResponse, bool) {
|
||||
var code uint32
|
||||
var body string
|
||||
switch t := err.(type) {
|
||||
case *centrifuge.Disconnect:
|
||||
code = t.Code
|
||||
body = t.Reason
|
||||
case centrifuge.Disconnect:
|
||||
code = t.Code
|
||||
body = t.Reason
|
||||
case *centrifuge.Error:
|
||||
code = t.Code
|
||||
body = t.Message
|
||||
default:
|
||||
}
|
||||
if code > 0 {
|
||||
for _, t := range transforms {
|
||||
if t.Code != code {
|
||||
continue
|
||||
}
|
||||
if t.To.Body == "" {
|
||||
t.To.Body = body
|
||||
}
|
||||
return t.To, true
|
||||
}
|
||||
}
|
||||
return configtypes.TransformedConnectErrorHttpResponse{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: http.StatusText(http.StatusInternalServerError),
|
||||
}, false
|
||||
}
|
||||
30
internal/tools/compare.go
Normal file
30
internal/tools/compare.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package tools
|
||||
|
||||
import "crypto/subtle"
|
||||
|
||||
// SecureCompare use constant time function to compare the two given array.
|
||||
func SecureCompare(given, actual []byte) bool {
|
||||
if subtle.ConstantTimeEq(int32(len(given)), int32(len(actual))) == 1 {
|
||||
return subtle.ConstantTimeCompare(given, actual) == 1
|
||||
}
|
||||
// Securely compare actual to itself to keep constant time, but always return false
|
||||
if subtle.ConstantTimeCompare(actual, actual) == 1 {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SecureCompareString use constant time function to compare the two given string.
|
||||
func SecureCompareString(given, actual string) bool {
|
||||
// The following code is incorrect:
|
||||
// return SecureCompare([]byte(given), []byte(actual))
|
||||
|
||||
if subtle.ConstantTimeEq(int32(len(given)), int32(len(actual))) == 1 {
|
||||
return subtle.ConstantTimeCompare([]byte(given), []byte(actual)) == 1
|
||||
}
|
||||
// Securely compare actual to itself to keep constant time, but always return false
|
||||
if subtle.ConstantTimeCompare([]byte(actual), []byte(actual)) == 1 {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
91
internal/tools/compare_test.go
Normal file
91
internal/tools/compare_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSecureCompare(t *testing.T) {
|
||||
type args struct {
|
||||
given []byte
|
||||
actual []byte
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "eq",
|
||||
args: args{
|
||||
given: []byte("test"),
|
||||
actual: []byte("test"),
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ne",
|
||||
args: args{
|
||||
given: []byte("boom"),
|
||||
actual: []byte("boot"),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "ne_len",
|
||||
args: args{
|
||||
given: []byte("booms"),
|
||||
actual: []byte("boom"),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SecureCompare(tt.args.given, tt.args.actual); got != tt.want {
|
||||
t.Errorf("SecureCompare() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureCompareString(t *testing.T) {
|
||||
type args struct {
|
||||
given string
|
||||
actual string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "eq",
|
||||
args: args{
|
||||
given: "test",
|
||||
actual: "test",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ne",
|
||||
args: args{
|
||||
given: "boom",
|
||||
actual: "boot",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "ne_len",
|
||||
args: args{
|
||||
given: "booms",
|
||||
actual: "boom",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SecureCompareString(tt.args.given, tt.args.actual); got != tt.want {
|
||||
t.Errorf("SecureCompareString() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
141
internal/tools/config.go
Normal file
141
internal/tools/config.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// PathExists returns whether the given file or directory exists or not
|
||||
func PathExists(path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
var jsonConfigTemplate = `{
|
||||
"client": {
|
||||
"token": {
|
||||
"hmac_secret_key": "{{.TokenSecret}}"
|
||||
},
|
||||
"allowed_origins": []
|
||||
},
|
||||
"admin": {
|
||||
"enabled": false,
|
||||
"password": "{{.AdminPassword}}",
|
||||
"secret": "{{.AdminSecret}}"
|
||||
},
|
||||
"http_api": {
|
||||
"key": "{{.APIKey}}"
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var tomlConfigTemplate = `[client]
|
||||
allowed_origins = []
|
||||
|
||||
[client.token]
|
||||
hmac_secret_key = "{{.TokenSecret}}"
|
||||
|
||||
[admin]
|
||||
enabled = false
|
||||
password = "{{.AdminPassword}}"
|
||||
secret = "{{.AdminSecret}}"
|
||||
|
||||
[http_api]
|
||||
key = "{{.APIKey}}"
|
||||
`
|
||||
|
||||
var yamlConfigTemplate = `client:
|
||||
token:
|
||||
hmac_secret_key: "{{.TokenSecret}}"
|
||||
allowed_origins: []
|
||||
|
||||
admin:
|
||||
enabled: false
|
||||
password: "{{.AdminPassword}}"
|
||||
secret: "{{.AdminSecret}}"
|
||||
|
||||
http_api:
|
||||
key: "{{.APIKey}}"
|
||||
`
|
||||
|
||||
// GenerateConfig generates configuration file at provided path.
|
||||
func GenerateConfig(f string) error {
|
||||
exists, err := PathExists(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errors.New("output config file already exists: " + f)
|
||||
}
|
||||
ext := filepath.Ext(f)
|
||||
|
||||
if len(ext) > 1 {
|
||||
ext = ext[1:]
|
||||
}
|
||||
|
||||
supportedExtensions := []string{"json", "toml", "yaml", "yml"}
|
||||
|
||||
var t *template.Template
|
||||
|
||||
switch ext {
|
||||
case "json":
|
||||
t, err = template.New("config").Parse(jsonConfigTemplate)
|
||||
case "toml":
|
||||
t, err = template.New("config").Parse(tomlConfigTemplate)
|
||||
case "yaml", "yml":
|
||||
t, err = template.New("config").Parse(yamlConfigTemplate)
|
||||
default:
|
||||
return errors.New("output config file must have one of supported extensions: " + strings.Join(supportedExtensions, ", "))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
_ = t.Execute(&output, struct {
|
||||
TokenSecret string
|
||||
AdminPassword string
|
||||
AdminSecret string
|
||||
APIKey string
|
||||
}{
|
||||
mustGenerateSecretKey(64),
|
||||
mustGenerateSecretKey(16),
|
||||
mustGenerateSecretKey(64),
|
||||
mustGenerateSecretKey(64),
|
||||
})
|
||||
|
||||
return os.WriteFile(f, output.Bytes(), 0600)
|
||||
}
|
||||
|
||||
func OptionalStringChoice(value string, choices []string) (string, error) {
|
||||
if value == "" {
|
||||
// Empty value is valid for optional configuration key.
|
||||
return value, nil
|
||||
}
|
||||
if !slices.Contains(choices, value) {
|
||||
return "", fmt.Errorf("invalid value: %s, possible choices are: %s", value, strings.Join(choices, ", "))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func mustGenerateSecretKey(byteLen int) string {
|
||||
key := make([]byte, byteLen)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(key)
|
||||
}
|
||||
15
internal/tools/file.go
Normal file
15
internal/tools/file.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func FileExists(filename string) bool {
|
||||
_, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false // File does not exist.
|
||||
}
|
||||
}
|
||||
return true // File exists.
|
||||
}
|
||||
15
internal/tools/http.go
Normal file
15
internal/tools/http.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package tools
|
||||
|
||||
// GetAcceptProtocolLabel returns the transport accept protocol label based on HTTP version.
|
||||
func GetAcceptProtocolLabel(protoMajor int) string {
|
||||
switch protoMajor {
|
||||
case 3:
|
||||
return "h3"
|
||||
case 2:
|
||||
return "h2"
|
||||
case 1:
|
||||
return "h1"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
35
internal/tools/interval_limiter.go
Normal file
35
internal/tools/interval_limiter.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IntervalLimiter permits an action at most once per configured interval. It is
|
||||
// safe for concurrent use and does no allocation or locking on the hot path -
|
||||
// just an atomic load (and a single CAS when the action is actually allowed),
|
||||
// so guarding a rare log line with it is effectively free when the interval has
|
||||
// not elapsed.
|
||||
type IntervalLimiter struct {
|
||||
intervalNanos int64
|
||||
lastNanos atomic.Int64
|
||||
}
|
||||
|
||||
// NewIntervalLimiter creates an IntervalLimiter that allows an action at most
|
||||
// once per interval.
|
||||
func NewIntervalLimiter(interval time.Duration) *IntervalLimiter {
|
||||
return &IntervalLimiter{intervalNanos: int64(interval)}
|
||||
}
|
||||
|
||||
// Allow reports whether the action may run now. Across all concurrent callers it
|
||||
// returns true at most once per interval. The first call always returns true.
|
||||
func (l *IntervalLimiter) Allow() bool {
|
||||
now := time.Now().UnixNano()
|
||||
last := l.lastNanos.Load()
|
||||
if now-last < l.intervalNanos {
|
||||
return false
|
||||
}
|
||||
// Only the caller that swaps in the new timestamp is allowed to proceed, so
|
||||
// concurrent callers in the same window can't all log.
|
||||
return l.lastNanos.CompareAndSwap(last, now)
|
||||
}
|
||||
35
internal/tools/interval_limiter_test.go
Normal file
35
internal/tools/interval_limiter_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIntervalLimiterThrottles(t *testing.T) {
|
||||
l := NewIntervalLimiter(time.Hour)
|
||||
require.True(t, l.Allow(), "first call should be allowed")
|
||||
require.False(t, l.Allow(), "second call within interval should be throttled")
|
||||
require.False(t, l.Allow())
|
||||
}
|
||||
|
||||
func TestIntervalLimiterConcurrentSingleAllow(t *testing.T) {
|
||||
l := NewIntervalLimiter(time.Hour)
|
||||
const n = 200
|
||||
var allowed int64
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if l.Allow() {
|
||||
atomic.AddInt64(&allowed, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
require.Equal(t, int64(1), allowed, "exactly one concurrent caller should be allowed per interval")
|
||||
}
|
||||
30
internal/tools/json.go
Normal file
30
internal/tools/json.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package tools
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// IsValidJSON checks if the given byte slice is valid JSON.
|
||||
func IsValidJSON(data []byte) bool {
|
||||
return json.Valid(data)
|
||||
}
|
||||
|
||||
// IsValidJSONObject checks if the given byte slice is valid JSON and specifically a JSON object.
|
||||
func IsValidJSONObject(data []byte) bool {
|
||||
if !IsValidJSON(data) {
|
||||
return false
|
||||
}
|
||||
// Find first non-whitespace character
|
||||
start := 0
|
||||
for start < len(data) && isWhitespace(data[start]) {
|
||||
start++
|
||||
}
|
||||
// Find last non-whitespace character
|
||||
end := len(data) - 1
|
||||
for end >= start && isWhitespace(data[end]) {
|
||||
end--
|
||||
}
|
||||
return start <= end && data[start] == '{' && data[end] == '}'
|
||||
}
|
||||
|
||||
func isWhitespace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||
}
|
||||
181
internal/tools/json_test.go
Normal file
181
internal/tools/json_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsValidJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty data",
|
||||
input: []byte{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "valid json object",
|
||||
input: []byte(`{"key": "value"}`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid json array",
|
||||
input: []byte(`[1, 2, 3]`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid json string",
|
||||
input: []byte(`"hello"`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid json number",
|
||||
input: []byte(`123`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid json boolean true",
|
||||
input: []byte(`true`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid json boolean false",
|
||||
input: []byte(`false`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid json null",
|
||||
input: []byte(`null`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid empty json object",
|
||||
input: []byte(`{}`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid empty json array",
|
||||
input: []byte(`[]`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid json - missing closing brace",
|
||||
input: []byte(`{"key": "value"`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json - plain text",
|
||||
input: []byte(`hello world`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json - trailing comma",
|
||||
input: []byte(`{"key": "value",}`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json - single quote",
|
||||
input: []byte(`{'key': 'value'}`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json - unquoted key",
|
||||
input: []byte(`{key: "value"}`),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsValidJSON(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("IsValidJSON() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidJSONObject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty data",
|
||||
input: []byte{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "valid json object",
|
||||
input: []byte(`{"key": "value"}`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid empty json object",
|
||||
input: []byte(`{}`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid json object with whitespace",
|
||||
input: []byte(` {"key": "value"} `),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid json object with newlines",
|
||||
input: []byte("{\n \"key\": \"value\"\n}"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid json array - should be false",
|
||||
input: []byte(`[1, 2, 3]`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "valid json string - should be false",
|
||||
input: []byte(`"hello"`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "valid json number - should be false",
|
||||
input: []byte(`123`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "valid json boolean - should be false",
|
||||
input: []byte(`true`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "valid json null - should be false",
|
||||
input: []byte(`null`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json - missing closing brace",
|
||||
input: []byte(`{"key": "value"`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json - plain text",
|
||||
input: []byte(`hello world`),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "invalid json - trailing comma",
|
||||
input: []byte(`{"key": "value",}`),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsValidJSONObject(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("IsValidJSONObject() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
59
internal/tools/logging.go
Normal file
59
internal/tools/logging.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StripPassword from URL address.
|
||||
func StripPassword(address string) string {
|
||||
u, err := url.Parse(address)
|
||||
if err != nil {
|
||||
return address
|
||||
}
|
||||
// Use url.Redacted, which replaces the password in the parsed userinfo before
|
||||
// re-encoding. The previous approach string-replaced the DECODED password in
|
||||
// the ENCODED URL, so a password containing a URL-special char (e.g. "@", a
|
||||
// space, or non-ASCII) never matched and was logged in the clear.
|
||||
return u.Redacted()
|
||||
}
|
||||
|
||||
// GetLogAddresses returns a string with addresses (concatenated with comma)
|
||||
// with password stripped from each address.
|
||||
func GetLogAddresses(addresses []string) string {
|
||||
cleanedAddresses := make([]string, 0, len(addresses))
|
||||
for _, a := range addresses {
|
||||
cleanedAddress := StripPassword(a)
|
||||
cleanedAddresses = append(cleanedAddresses, cleanedAddress)
|
||||
}
|
||||
return strings.Join(cleanedAddresses, ", ")
|
||||
}
|
||||
|
||||
// RedactedLogURLs prepares URLs to be logged or shown in UI stripping auth info from them.
|
||||
func RedactedLogURLs(urls ...string) []string {
|
||||
var result []string
|
||||
|
||||
for _, input := range urls {
|
||||
// Split the input by commas to handle comma-separated URLs.
|
||||
urlParts := strings.Split(input, ",")
|
||||
var cleanedParts []string
|
||||
|
||||
for _, urlString := range urlParts {
|
||||
parsedURL, err := url.Parse(strings.TrimSpace(urlString))
|
||||
var cleanedURL string
|
||||
if err != nil {
|
||||
cleanedURL = "<invalid_url>"
|
||||
} else {
|
||||
cleanedURL = parsedURL.Redacted()
|
||||
}
|
||||
cleanedParts = append(cleanedParts, cleanedURL)
|
||||
}
|
||||
|
||||
// Combine the cleaned URLs back into a comma-separated string.
|
||||
if len(cleanedParts) > 0 {
|
||||
result = append(result, strings.Join(cleanedParts, ","))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
67
internal/tools/logging_test.go
Normal file
67
internal/tools/logging_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetLogURLs tests the RedactedLogURLs function using Redacted method.
|
||||
func TestGetLogURLs(t *testing.T) {
|
||||
t.Run("Single URL with auth info", func(t *testing.T) {
|
||||
input := "https://user:password@domain.com/resource"
|
||||
expected := []string{"https://user:xxxxx@domain.com/resource"}
|
||||
actual := RedactedLogURLs(input)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
|
||||
t.Run("Multiple URLs with mixed auth info", func(t *testing.T) {
|
||||
input := "https://user:pass@domain.com/resource,https://another.com"
|
||||
expected := []string{"https://user:xxxxx@domain.com/resource,https://another.com"}
|
||||
actual := RedactedLogURLs(input)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
|
||||
t.Run("Multiple URLs with mixed spaces", func(t *testing.T) {
|
||||
input := "https://user:pass@domain.com/resource, https://another.com"
|
||||
expected := []string{"https://user:xxxxx@domain.com/resource,https://another.com"}
|
||||
actual := RedactedLogURLs(input)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
|
||||
t.Run("Single URL without auth info", func(t *testing.T) {
|
||||
input := "https://domain.com/resource"
|
||||
expected := []string{"https://domain.com/resource"}
|
||||
actual := RedactedLogURLs(input)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
|
||||
t.Run("Invalid URL", func(t *testing.T) {
|
||||
input := "://invalid-url"
|
||||
expected := []string{"<invalid_url>"}
|
||||
actual := RedactedLogURLs(input)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
|
||||
t.Run("Mixed valid and invalid URLs", func(t *testing.T) {
|
||||
input := "https://user:pass@domain.com/resource, ://invalid-url, https://valid.com"
|
||||
expected := []string{"https://user:xxxxx@domain.com/resource,<invalid_url>,https://valid.com"}
|
||||
actual := RedactedLogURLs(input)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
|
||||
t.Run("Multiple comma-separated URLs with auth", func(t *testing.T) {
|
||||
input := "https://user:pass@domain.com, https://admin:admin@another.com, httpss://example.com/resource"
|
||||
expected := []string{"https://user:xxxxx@domain.com,https://admin:xxxxx@another.com,httpss://example.com/resource"}
|
||||
actual := RedactedLogURLs(input)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
|
||||
t.Run("GRPC addresses work correctly", func(t *testing.T) {
|
||||
// We use such format for GRPC proxy config.
|
||||
input := []string{"grpc://user:pass@127.0.0.1:9000", "grpc://127.0.0.1:10000"}
|
||||
expected := []string{"grpc://user:xxxxx@127.0.0.1:9000", "grpc://127.0.0.1:10000"}
|
||||
actual := RedactedLogURLs(input...)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
}
|
||||
14
internal/tools/pid.go
Normal file
14
internal/tools/pid.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func WritePidFile(pidFile string) error {
|
||||
if pidFile == "" {
|
||||
return nil
|
||||
}
|
||||
pid := []byte(strconv.Itoa(os.Getpid()) + "\n")
|
||||
return os.WriteFile(pidFile, pid, 0600)
|
||||
}
|
||||
287
internal/tools/test_helpers.go
Normal file
287
internal/tools/test_helpers.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/proxyproto"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
)
|
||||
|
||||
// TestTransport - test transport
|
||||
type TestTransport struct {
|
||||
mu sync.Mutex
|
||||
sink chan []byte
|
||||
closed bool
|
||||
closeCh chan struct{}
|
||||
disconnect *centrifuge.Disconnect
|
||||
protoType centrifuge.ProtocolType
|
||||
}
|
||||
|
||||
// NewTestTransport - builder for TestTransport
|
||||
func NewTestTransport() *TestTransport {
|
||||
return &TestTransport{
|
||||
protoType: centrifuge.ProtocolTypeJSON,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Write - ...
|
||||
func (t *TestTransport) Write(message []byte) error {
|
||||
return t.WriteMany(message)
|
||||
}
|
||||
|
||||
// WriteMany - ...
|
||||
func (t *TestTransport) WriteMany(messages ...[]byte) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.closed {
|
||||
return io.EOF
|
||||
}
|
||||
for _, buf := range messages {
|
||||
dataCopy := make([]byte, len(buf))
|
||||
copy(dataCopy, buf)
|
||||
if t.sink != nil {
|
||||
t.sink <- dataCopy
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Name - ...
|
||||
func (t *TestTransport) Name() string {
|
||||
return "test_transport"
|
||||
}
|
||||
|
||||
func (t *TestTransport) AcceptProtocol() string {
|
||||
return "h1"
|
||||
}
|
||||
|
||||
// Protocol - ...
|
||||
func (t *TestTransport) Protocol() centrifuge.ProtocolType {
|
||||
return t.protoType
|
||||
}
|
||||
|
||||
// ProtocolVersion returns transport protocol version.
|
||||
func (t *TestTransport) ProtocolVersion() centrifuge.ProtocolVersion {
|
||||
return centrifuge.ProtocolVersion2
|
||||
}
|
||||
|
||||
// Unidirectional - ...
|
||||
func (t *TestTransport) Unidirectional() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// DisabledPushFlags - ...
|
||||
func (t *TestTransport) DisabledPushFlags() uint64 {
|
||||
return centrifuge.PushFlagDisconnect
|
||||
}
|
||||
|
||||
// PingPongConfig ...
|
||||
func (t *TestTransport) PingPongConfig() centrifuge.PingPongConfig {
|
||||
return centrifuge.PingPongConfig{}
|
||||
}
|
||||
|
||||
// Emulation ...
|
||||
func (t *TestTransport) Emulation() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Close - ...
|
||||
func (t *TestTransport) Close(disconnect centrifuge.Disconnect) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.closed {
|
||||
return nil
|
||||
}
|
||||
t.disconnect = &disconnect
|
||||
t.closed = true
|
||||
close(t.closeCh)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NodeWithMemoryEngineNoHandlers - builder for centrifuge node with memory engine
|
||||
func NodeWithMemoryEngineNoHandlers() *centrifuge.Node {
|
||||
n, err := centrifuge.New(centrifuge.Config{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = n.Run()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// NodeWithMemoryEngine - builder for centrifuge node with memory engine
|
||||
func NodeWithMemoryEngine() *centrifuge.Node {
|
||||
n := NodeWithMemoryEngineNoHandlers()
|
||||
n.OnConnect(func(client *centrifuge.Client) {
|
||||
client.OnSubscribe(func(_ centrifuge.SubscribeEvent, cb centrifuge.SubscribeCallback) {
|
||||
cb(centrifuge.SubscribeReply{}, nil)
|
||||
})
|
||||
client.OnPublish(func(_ centrifuge.PublishEvent, cb centrifuge.PublishCallback) {
|
||||
cb(centrifuge.PublishReply{}, nil)
|
||||
})
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
type CommonHTTPProxyTestCase struct {
|
||||
Node *centrifuge.Node
|
||||
Client *centrifuge.Client
|
||||
ClientCloseFunc centrifuge.ClientCloseFunc
|
||||
Server *httptest.Server
|
||||
Mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewCommonHTTPProxyTestCase(ctx context.Context) *CommonHTTPProxyTestCase {
|
||||
node := NodeWithMemoryEngineNoHandlers()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
|
||||
client, closeFn, err := centrifuge.NewClient(ctx, node, NewTestTransport())
|
||||
if err != nil {
|
||||
log.Fatalf("could not create centrifuge client: %v", err)
|
||||
}
|
||||
|
||||
return &CommonHTTPProxyTestCase{
|
||||
Node: node,
|
||||
Client: client,
|
||||
ClientCloseFunc: closeFn,
|
||||
Server: server,
|
||||
Mux: mux,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CommonHTTPProxyTestCase) Teardown() {
|
||||
defer func() { _ = c.Node.Shutdown(context.Background()) }()
|
||||
defer func() { _ = c.ClientCloseFunc() }()
|
||||
c.Server.Close()
|
||||
}
|
||||
|
||||
type CommonGRPCProxyTestCase struct {
|
||||
Node *centrifuge.Node
|
||||
Client *centrifuge.Client
|
||||
ClientCloseFunc centrifuge.ClientCloseFunc
|
||||
Server *grpc.Server
|
||||
Listener *bufconn.Listener
|
||||
}
|
||||
|
||||
func NewCommonGRPCProxyTestCase(ctx context.Context, srv proxyproto.CentrifugoProxyServer) *CommonGRPCProxyTestCase {
|
||||
node := NodeWithMemoryEngineNoHandlers()
|
||||
|
||||
client, closeFn, err := centrifuge.NewClient(ctx, node, NewTestTransport())
|
||||
if err != nil {
|
||||
log.Fatalf("could not create centrifuge client: %v", err)
|
||||
}
|
||||
|
||||
listener := bufconn.Listen(1024)
|
||||
server := grpc.NewServer()
|
||||
proxyproto.RegisterCentrifugoProxyServer(server, srv)
|
||||
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||
log.Fatalf("GRPC server exited with error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return &CommonGRPCProxyTestCase{
|
||||
Node: node,
|
||||
Client: client,
|
||||
ClientCloseFunc: closeFn,
|
||||
Server: server,
|
||||
Listener: listener,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CommonGRPCProxyTestCase) Teardown() {
|
||||
defer func() { _ = c.Node.Shutdown(context.Background()) }()
|
||||
defer func() { _ = c.ClientCloseFunc() }()
|
||||
c.Server.Stop()
|
||||
}
|
||||
|
||||
type TestClientMock struct {
|
||||
IDFunc func() string
|
||||
UserIDFunc func() string
|
||||
IsSubscribedFunc func(string) bool
|
||||
ContextFunc func() context.Context
|
||||
TransportFunc func() centrifuge.TransportInfo
|
||||
storageMu sync.Mutex
|
||||
storage map[string]any
|
||||
}
|
||||
|
||||
func (m *TestClientMock) Send(bytes []byte) error {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (m *TestClientMock) Disconnect(disconnect ...centrifuge.Disconnect) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (m *TestClientMock) WritePublication(channel string, publication *centrifuge.Publication, sp centrifuge.StreamPosition) error {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (m *TestClientMock) ID() string {
|
||||
if m.IDFunc != nil {
|
||||
return m.IDFunc()
|
||||
}
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *TestClientMock) UserID() string {
|
||||
if m.UserIDFunc != nil {
|
||||
return m.UserIDFunc()
|
||||
}
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *TestClientMock) IsSubscribed(s string) bool {
|
||||
if m.IsSubscribedFunc != nil {
|
||||
return m.IsSubscribedFunc(s)
|
||||
}
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *TestClientMock) Context() context.Context {
|
||||
if m.ContextFunc != nil {
|
||||
return m.ContextFunc()
|
||||
}
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *TestClientMock) Transport() centrifuge.TransportInfo {
|
||||
if m.TransportFunc != nil {
|
||||
return m.TransportFunc()
|
||||
}
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (m *TestClientMock) AcquireStorage() (map[string]any, func(map[string]any)) {
|
||||
m.storageMu.Lock()
|
||||
if m.storage == nil {
|
||||
m.storage = map[string]any{}
|
||||
}
|
||||
return m.storage, func(updatedStorage map[string]any) {
|
||||
m.storage = updatedStorage
|
||||
m.storageMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TestClientMock) Unsubscribe(ch string, unsubscribe ...centrifuge.Unsubscribe) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
Reference in New Issue
Block a user