Move common client code to separate package.

This commit is contained in:
Joachim Bauch 2026-01-08 14:00:41 +01:00
commit 3c41dcdbce
No known key found for this signature in database
GPG key ID: 77C1D22D53E15F02
17 changed files with 1024 additions and 489 deletions

561
client/client.go Normal file
View file

@ -0,0 +1,561 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2017 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/mailru/easyjson"
"github.com/strukturag/nextcloud-spreed-signaling/api"
"github.com/strukturag/nextcloud-spreed-signaling/geoip"
"github.com/strukturag/nextcloud-spreed-signaling/internal"
"github.com/strukturag/nextcloud-spreed-signaling/log"
"github.com/strukturag/nextcloud-spreed-signaling/pool"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer.
maxMessageSize = 64 * 1024
)
func init() {
RegisterClientStats()
}
var (
InvalidFormat = api.NewError("invalid_format", "Invalid data format.")
bufferPool pool.BufferPool
)
type WritableClientMessage interface {
json.Marshaler
CloseAfterSend(session api.RoomAware) bool
}
type HandlerClient interface {
Context() context.Context
RemoteAddr() string
Country() geoip.Country
UserAgent() string
IsConnected() bool
SendError(e *api.Error) bool
SendByeResponse(message *api.ClientMessage) bool
SendByeResponseWithReason(message *api.ClientMessage, reason string) bool
SendMessage(message WritableClientMessage) bool
Close()
}
type Handler interface {
GetSessionId() api.PublicSessionId
OnClosed()
OnMessageReceived([]byte)
OnRTTReceived(time.Duration)
}
type GeoIpHandler interface {
OnLookupCountry(addr string) geoip.Country
}
type InRoomHandler interface {
IsInRoom(string) bool
}
type SessionCloserHandler interface {
CloseSession()
}
type Client struct {
logger log.Logger
ctx context.Context
// +checklocks:mu
conn *websocket.Conn
addr string
agent string
closed atomic.Int32
country *geoip.Country
logRTT bool
handlerMu sync.RWMutex
// +checklocks:handlerMu
handler Handler
sessionId atomic.Pointer[api.PublicSessionId]
mu sync.Mutex
closer *internal.Closer
closeOnce sync.Once
messagesDone chan struct{}
messageChan chan *bytes.Buffer
}
func (c *Client) SetConn(ctx context.Context, conn *websocket.Conn, remoteAddress string, agent string, logRTT bool, handler Handler) {
c.mu.Lock()
defer c.mu.Unlock()
c.logger = log.LoggerFromContext(ctx)
c.ctx = ctx
c.conn = conn
c.addr = remoteAddress
c.agent = agent
c.logRTT = logRTT
c.SetHandler(handler)
c.closer = internal.NewCloser()
c.messageChan = make(chan *bytes.Buffer, 16)
c.messagesDone = make(chan struct{})
}
func (c *Client) GetConn() *websocket.Conn {
c.mu.Lock()
defer c.mu.Unlock()
return c.conn
}
func (c *Client) SetHandler(handler Handler) {
c.handlerMu.Lock()
defer c.handlerMu.Unlock()
c.handler = handler
}
func (c *Client) getHandler() Handler {
c.handlerMu.RLock()
defer c.handlerMu.RUnlock()
return c.handler
}
func (c *Client) Context() context.Context {
return c.ctx
}
func (c *Client) IsConnected() bool {
return c.closed.Load() == 0
}
func (c *Client) SetSessionId(sessionId api.PublicSessionId) {
c.sessionId.Store(&sessionId)
}
func (c *Client) GetSessionId() api.PublicSessionId {
sessionId := c.sessionId.Load()
if sessionId == nil {
sessionId := c.getHandler().GetSessionId()
if sessionId == "" {
return ""
}
return sessionId
}
return *sessionId
}
func (c *Client) RemoteAddr() string {
return c.addr
}
func (c *Client) UserAgent() string {
return c.agent
}
func (c *Client) Country() geoip.Country {
if c.country == nil {
var country geoip.Country
if handler, ok := c.getHandler().(GeoIpHandler); ok {
country = handler.OnLookupCountry(c.addr)
} else {
country = geoip.UnknownCountry
}
c.country = &country
}
return *c.country
}
func (c *Client) IsInRoom(id string) bool {
if handler, ok := c.getHandler().(InRoomHandler); ok {
return handler.IsInRoom(id)
}
return false
}
func (c *Client) Close() {
if c.closed.Load() >= 2 {
// Prevent reentrant call in case this was the second closing
// step. Would otherwise deadlock in the "Once.Do" call path
// through "Hub.processUnregister" (which calls "Close" again).
return
}
c.closeOnce.Do(func() {
c.doClose()
})
}
func (c *Client) doClose() {
closed := c.closed.Add(1)
switch closed {
case 1:
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) // nolint
c.conn.Close()
c.conn = nil
}
case 2:
// Both the read pump and message processing must be finished before closing.
c.closer.Close()
<-c.messagesDone
c.getHandler().OnClosed()
}
}
func (c *Client) SendError(e *api.Error) bool {
message := &api.ServerMessage{
Type: "error",
Error: e,
}
return c.SendMessage(message)
}
func (c *Client) SendByeResponse(message *api.ClientMessage) bool {
return c.SendByeResponseWithReason(message, "")
}
func (c *Client) SendByeResponseWithReason(message *api.ClientMessage, reason string) bool {
response := &api.ServerMessage{
Type: "bye",
}
if message != nil {
response.Id = message.Id
}
if reason != "" {
if response.Bye == nil {
response.Bye = &api.ByeServerMessage{}
}
response.Bye.Reason = reason
}
return c.SendMessage(response)
}
func (c *Client) SendMessage(message WritableClientMessage) bool {
return c.writeMessage(message)
}
func (c *Client) ReadPump() {
defer func() {
close(c.messageChan)
c.Close()
}()
go c.processMessages()
addr := c.RemoteAddr()
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
c.logger.Printf("Connection from %s closed while starting readPump", addr)
return
}
conn.SetReadLimit(maxMessageSize)
conn.SetPongHandler(func(msg string) error {
now := time.Now()
conn.SetReadDeadline(now.Add(pongWait)) // nolint
if msg == "" {
return nil
}
statsClientBytesTotal.WithLabelValues("incoming").Add(float64(len(msg)))
if ts, err := strconv.ParseInt(msg, 10, 64); err == nil {
rtt := now.Sub(time.Unix(0, ts))
if c.logRTT {
rtt_ms := rtt.Nanoseconds() / time.Millisecond.Nanoseconds()
if sessionId := c.GetSessionId(); sessionId != "" {
c.logger.Printf("Client %s has RTT of %d ms (%s)", sessionId, rtt_ms, rtt)
} else {
c.logger.Printf("Client from %s has RTT of %d ms (%s)", addr, rtt_ms, rtt)
}
}
statsClientRTT.Observe(float64(rtt.Milliseconds()))
c.getHandler().OnRTTReceived(rtt)
}
return nil
})
for {
conn.SetReadDeadline(time.Now().Add(pongWait)) // nolint
messageType, reader, err := conn.NextReader()
if err != nil {
// Gorilla websocket hides the original net.Error, so also compare error messages
if errors.Is(err, net.ErrClosed) || errors.Is(err, websocket.ErrCloseSent) || strings.Contains(err.Error(), net.ErrClosed.Error()) {
break
} else if _, ok := err.(*websocket.CloseError); !ok || websocket.IsUnexpectedCloseError(err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
websocket.CloseNoStatusReceived) {
if sessionId := c.GetSessionId(); sessionId != "" {
c.logger.Printf("Error reading from client %s: %v", sessionId, err)
} else {
c.logger.Printf("Error reading from %s: %v", addr, err)
}
}
break
}
if messageType != websocket.TextMessage {
if sessionId := c.GetSessionId(); sessionId != "" {
c.logger.Printf("Unsupported message type %v from client %s", messageType, sessionId)
} else {
c.logger.Printf("Unsupported message type %v from %s", messageType, addr)
}
c.SendError(InvalidFormat)
continue
}
decodeBuffer, err := bufferPool.ReadAll(reader)
if err != nil {
if sessionId := c.GetSessionId(); sessionId != "" {
c.logger.Printf("Error reading message from client %s: %v", sessionId, err)
} else {
c.logger.Printf("Error reading message from %s: %v", addr, err)
}
break
}
// Stop processing if the client was closed.
if !c.IsConnected() {
bufferPool.Put(decodeBuffer)
break
}
statsClientBytesTotal.WithLabelValues("incoming").Add(float64(decodeBuffer.Len()))
statsClientMessagesTotal.WithLabelValues("incoming").Inc()
c.messageChan <- decodeBuffer
}
}
func (c *Client) processMessages() {
for {
buffer := <-c.messageChan
if buffer == nil {
break
}
c.getHandler().OnMessageReceived(buffer.Bytes())
bufferPool.Put(buffer)
}
close(c.messagesDone)
c.doClose()
}
type counterWriter struct {
w io.Writer
counter *int
}
func (w *counterWriter) Write(p []byte) (int, error) {
written, err := w.w.Write(p)
if written > 0 {
*w.counter += written
}
return written, err
}
// +checklocks:c.mu
func (c *Client) writeInternal(message json.Marshaler) bool {
var closeData []byte
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint
writer, err := c.conn.NextWriter(websocket.TextMessage)
var written int
if err == nil {
if m, ok := (any(message)).(easyjson.Marshaler); ok {
written, err = easyjson.MarshalToWriter(m, writer)
} else {
err = json.NewEncoder(&counterWriter{
w: writer,
counter: &written,
}).Encode(message)
}
}
if err == nil {
err = writer.Close()
}
if err != nil {
if err == websocket.ErrCloseSent {
// Already sent a "close", won't be able to send anything else.
return false
}
if sessionId := c.GetSessionId(); sessionId != "" {
c.logger.Printf("Could not send message %+v to client %s: %v", message, sessionId, err)
} else {
c.logger.Printf("Could not send message %+v to %s: %v", message, c.RemoteAddr(), err)
}
closeData = websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "")
goto close
}
statsClientBytesTotal.WithLabelValues("outgoing").Add(float64(written))
statsClientMessagesTotal.WithLabelValues("outgoing").Inc()
return true
close:
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint
if err := c.conn.WriteMessage(websocket.CloseMessage, closeData); err != nil {
if sessionId := c.GetSessionId(); sessionId != "" {
c.logger.Printf("Could not send close message to client %s: %v", sessionId, err)
} else {
c.logger.Printf("Could not send close message to %s: %v", c.RemoteAddr(), err)
}
}
return false
}
func (c *Client) writeError(e error) bool { // nolint
message := &api.ServerMessage{
Type: "error",
Error: api.NewError("internal_error", e.Error()),
}
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return false
}
if !c.writeMessageLocked(message) {
return false
}
closeData := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, e.Error())
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint
if err := c.conn.WriteMessage(websocket.CloseMessage, closeData); err != nil {
if sessionId := c.GetSessionId(); sessionId != "" {
c.logger.Printf("Could not send close message to client %s: %v", sessionId, err)
} else {
c.logger.Printf("Could not send close message to %s: %v", c.RemoteAddr(), err)
}
}
return false
}
func (c *Client) writeMessage(message WritableClientMessage) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return false
}
return c.writeMessageLocked(message)
}
// +checklocks:c.mu
func (c *Client) writeMessageLocked(message WritableClientMessage) bool {
if !c.writeInternal(message) {
return false
}
if message.CloseAfterSend(c) {
go func() {
if sc, ok := c.getHandler().(SessionCloserHandler); ok {
sc.CloseSession()
}
c.Close()
}()
}
return true
}
func (c *Client) sendPing() bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return false
}
now := time.Now().UnixNano()
msg := strconv.FormatInt(now, 10)
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) // nolint
if err := c.conn.WriteMessage(websocket.PingMessage, []byte(msg)); err != nil {
if sessionId := c.GetSessionId(); sessionId != "" {
c.logger.Printf("Could not send ping to client %s: %v", sessionId, err)
} else {
c.logger.Printf("Could not send ping to %s: %v", c.RemoteAddr(), err)
}
return false
}
statsClientBytesTotal.WithLabelValues("outgoing").Add(float64(len(msg)))
return true
}
func (c *Client) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
}()
// Fetch initial RTT before any messages have been sent to the client.
c.sendPing()
for {
select {
case <-ticker.C:
if !c.sendPing() {
return
}
case <-c.closer.C:
return
}
}
}

315
client/client_test.go Normal file
View file

@ -0,0 +1,315 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2025 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/strukturag/nextcloud-spreed-signaling/api"
"github.com/strukturag/nextcloud-spreed-signaling/geoip"
"github.com/strukturag/nextcloud-spreed-signaling/log"
)
func TestCounterWriter(t *testing.T) {
t.Parallel()
assert := assert.New(t)
var b bytes.Buffer
var written int
w := &counterWriter{
w: &b,
counter: &written,
}
if count, err := w.Write(nil); assert.NoError(err) && assert.Equal(0, count) {
assert.Equal(0, written)
}
if count, err := w.Write([]byte("foo")); assert.NoError(err) && assert.Equal(3, count) {
assert.Equal(3, written)
}
}
type serverClient struct {
Client
t *testing.T
handler *testHandler
id string
received atomic.Uint32
sessionClosed atomic.Bool
}
func newTestClient(h *testHandler, r *http.Request, conn *websocket.Conn, id uint64) *serverClient {
result := &serverClient{
t: h.t,
handler: h,
id: fmt.Sprintf("session-%d", id),
}
addr := r.RemoteAddr
if host, _, err := net.SplitHostPort(addr); err == nil {
addr = host
}
logger := log.NewLoggerForTest(h.t)
ctx := log.NewLoggerContext(r.Context(), logger)
result.SetConn(ctx, conn, addr, r.Header.Get("User-Agent"), false, result)
return result
}
func (c *serverClient) WaitReceived(ctx context.Context, count uint32) error {
for {
if err := ctx.Err(); err != nil {
return err
} else if c.received.Load() >= count {
return nil
}
time.Sleep(time.Millisecond)
}
}
func (c *serverClient) GetSessionId() api.PublicSessionId {
return api.PublicSessionId(c.id)
}
func (c *serverClient) OnClosed() {
c.handler.removeClient(c)
}
func (c *serverClient) OnMessageReceived(message []byte) {
switch c.received.Add(1) {
case 1:
var s string
if err := json.Unmarshal(message, &s); assert.NoError(c.t, err) {
assert.Equal(c.t, "Hello world!", s)
c.SendMessage(&api.ServerMessage{
Type: "welcome",
Welcome: &api.WelcomeServerMessage{
Version: "1.0",
},
})
}
case 2:
var s string
if err := json.Unmarshal(message, &s); assert.NoError(c.t, err) {
assert.Equal(c.t, "Send error", s)
c.SendError(api.NewError("test_error", "This is a test error."))
}
case 3:
var s string
if err := json.Unmarshal(message, &s); assert.NoError(c.t, err) {
assert.Equal(c.t, "Send bye", s)
c.SendByeResponseWithReason(nil, "Go away!")
}
}
}
func (c *serverClient) OnRTTReceived(rtt time.Duration) {
}
func (c *serverClient) OnLookupCountry(addr string) geoip.Country {
return "DE"
}
func (c *serverClient) IsInRoom(roomId string) bool {
return false
}
func (c *serverClient) CloseSession() {
if c.sessionClosed.Swap(true) {
assert.Fail(c.t, "session closed more than once")
}
}
type testHandler struct {
mu sync.Mutex
t *testing.T
upgrader websocket.Upgrader
id atomic.Uint64
// +checklocks:mu
activeClients map[string]*serverClient
// +checklocks:mu
allClients []*serverClient
}
func newTestHandler(t *testing.T) *testHandler {
return &testHandler{
t: t,
activeClients: make(map[string]*serverClient),
}
}
func (h *testHandler) addClient(client *serverClient) {
h.mu.Lock()
defer h.mu.Unlock()
h.activeClients[client.id] = client
h.allClients = append(h.allClients, client)
}
func (h *testHandler) removeClient(client *serverClient) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.activeClients, client.id)
}
func (h *testHandler) getClients() []*serverClient {
h.mu.Lock()
defer h.mu.Unlock()
return slices.Clone(h.allClients)
}
func (h *testHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := h.upgrader.Upgrade(w, r, nil)
if !assert.NoError(h.t, err) {
return
}
id := h.id.Add(1)
client := newTestClient(h, r, conn, id)
h.addClient(client)
go client.WritePump()
client.ReadPump()
}
type localClient struct {
t *testing.T
conn *websocket.Conn
}
func newLocalClient(t *testing.T, url string) *localClient {
t.Helper()
conn, _, err := websocket.DefaultDialer.DialContext(t.Context(), url, nil)
require.NoError(t, err)
return &localClient{
t: t,
conn: conn,
}
}
func (c *localClient) Close() error {
err := c.conn.Close()
if errors.Is(err, net.ErrClosed) {
err = nil
}
return err
}
func (c *localClient) WriteJSON(v any) error {
return c.conn.WriteJSON(v)
}
func (c *localClient) ReadJSON(v any) error {
return c.conn.ReadJSON(v)
}
func TestClient(t *testing.T) {
t.Parallel()
require := require.New(t)
assert := assert.New(t)
serverHandler := newTestHandler(t)
server := httptest.NewServer(serverHandler)
t.Cleanup(func() {
server.Close()
})
client := newLocalClient(t, strings.ReplaceAll(server.URL, "http://", "ws://"))
t.Cleanup(func() {
assert.NoError(client.Close())
})
var msg api.ServerMessage
require.NoError(client.WriteJSON("Hello world!"))
if assert.NoError(client.ReadJSON(&msg)) &&
assert.Equal("welcome", msg.Type) &&
assert.NotNil(msg.Welcome) {
assert.Equal("1.0", msg.Welcome.Version)
}
if clients := serverHandler.getClients(); assert.Len(clients, 1) {
assert.False(clients[0].sessionClosed.Load())
assert.EqualValues(1, clients[0].received.Load())
}
require.NoError(client.WriteJSON("Send error"))
if assert.NoError(client.ReadJSON(&msg)) &&
assert.Equal("error", msg.Type) &&
assert.NotNil(msg.Error) {
assert.Equal("test_error", msg.Error.Code)
assert.Equal("This is a test error.", msg.Error.Message)
}
if clients := serverHandler.getClients(); assert.Len(clients, 1) {
assert.False(clients[0].sessionClosed.Load())
assert.EqualValues(2, clients[0].received.Load())
}
require.NoError(client.WriteJSON("Send bye"))
if assert.NoError(client.ReadJSON(&msg)) &&
assert.Equal("bye", msg.Type) &&
assert.NotNil(msg.Bye) {
assert.Equal("Go away!", msg.Bye.Reason)
}
if clients := serverHandler.getClients(); assert.Len(clients, 1) {
assert.EqualValues(3, clients[0].received.Load())
}
// Sending a "bye" will close the connection.
var we *websocket.CloseError
if err := client.ReadJSON(&msg); assert.ErrorAs(err, &we) {
assert.Equal(websocket.CloseNormalClosure, we.Code)
assert.Empty(we.Text)
}
if clients := serverHandler.getClients(); assert.Len(clients, 1) {
assert.True(clients[0].sessionClosed.Load())
assert.EqualValues(3, clients[0].received.Load())
}
}

93
client/ip.go Normal file
View file

@ -0,0 +1,93 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2026 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package client
import (
"net"
"net/http"
"slices"
"strings"
"github.com/strukturag/nextcloud-spreed-signaling/container"
)
var (
DefaultTrustedProxies = container.DefaultPrivateIPs()
)
func GetRealUserIP(r *http.Request, trusted *container.IPList) string {
addr := r.RemoteAddr
if host, _, err := net.SplitHostPort(addr); err == nil {
addr = host
}
ip := net.ParseIP(addr)
if len(ip) == 0 {
return addr
}
// Don't check any headers if the server can be reached by untrusted clients directly.
if trusted == nil || !trusted.Contains(ip) {
return addr
}
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
if ip := net.ParseIP(realIP); len(ip) > 0 {
return realIP
}
}
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#selecting_an_ip_address
forwarded := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
if len(forwarded) > 0 {
slices.Reverse(forwarded)
var lastTrusted string
for _, hop := range forwarded {
hop = strings.TrimSpace(hop)
// Make sure to remove any port.
if host, _, err := net.SplitHostPort(hop); err == nil {
hop = host
}
ip := net.ParseIP(hop)
if len(ip) == 0 {
continue
}
if trusted.Contains(ip) {
lastTrusted = hop
continue
}
return hop
}
// If all entries in the "X-Forwarded-For" list are trusted, the left-most
// will be the client IP. This can happen if a subnet is trusted and the
// client also has an IP from this subnet.
if lastTrusted != "" {
return lastTrusted
}
}
return addr
}

278
client/ip_test.go Normal file
View file

@ -0,0 +1,278 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2026 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package client
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/strukturag/nextcloud-spreed-signaling/container"
)
func TestGetRealUserIP(t *testing.T) {
t.Parallel()
testcases := []struct {
expected string
headers http.Header
trusted string
addr string
}{
{
"192.168.1.2",
nil,
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"invalid-ip",
nil,
"192.168.0.0/16",
"invalid-ip",
},
{
"invalid-ip",
nil,
"192.168.0.0/16",
"invalid-ip:12345",
},
{
"10.11.12.13",
nil,
"192.168.0.0/16",
"10.11.12.13:23456",
},
{
"10.11.12.13",
http.Header{
http.CanonicalHeaderKey("x-real-ip"): []string{"10.11.12.13"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"2002:db8::1",
http.Header{
http.CanonicalHeaderKey("x-real-ip"): []string{"2002:db8::1"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"11.12.13.14",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"11.12.13.14",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14:1234, 192.168.30.32:2345"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"10.11.12.13",
http.Header{
http.CanonicalHeaderKey("x-real-ip"): []string{"10.11.12.13"},
},
"2001:db8::/48",
"[2001:db8::1]:23456",
},
{
"2002:db8::1",
http.Header{
http.CanonicalHeaderKey("x-real-ip"): []string{"2002:db8::1"},
},
"2001:db8::/48",
"[2001:db8::1]:23456",
},
{
"2002:db8::1",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 192.168.30.32"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"2002:db8::1",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 2001:db8::1"},
},
"192.168.0.0/16, 2001:db8::/48",
"192.168.1.2:23456",
},
{
"2002:db8::1",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 192.168.30.32"},
},
"192.168.0.0/16, 2001:db8::/48",
"[2001:db8::1]:23456",
},
{
"2002:db8::1",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"2002:db8::1, 2001:db8::2"},
},
"2001:db8::/48",
"[2001:db8::1]:23456",
},
// "X-Real-IP" has preference before "X-Forwarded-For"
{
"10.11.12.13",
http.Header{
http.CanonicalHeaderKey("x-real-ip"): []string{"10.11.12.13"},
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
// Multiple "X-Forwarded-For" headers are merged.
{
"11.12.13.14",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14", "192.168.30.32"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"11.12.13.14",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"1.2.3.4", "11.12.13.14", "192.168.30.32"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"11.12.13.14",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"1.2.3.4", "2.3.4.5", "11.12.13.14", "192.168.31.32", "192.168.30.32"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
// Headers are ignored if coming from untrusted clients.
{
"10.11.12.13",
http.Header{
http.CanonicalHeaderKey("x-real-ip"): []string{"11.12.13.14"},
},
"192.168.0.0/16",
"10.11.12.13:23456",
},
{
"10.11.12.13",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"},
},
"192.168.0.0/16",
"10.11.12.13:23456",
},
// X-Forwarded-For is filtered for trusted proxies.
{
"1.2.3.4",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 1.2.3.4"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"1.2.3.4",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 1.2.3.4, 192.168.2.3"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"10.11.12.13",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 1.2.3.4"},
},
"192.168.0.0/16",
"10.11.12.13:23456",
},
// Invalid IPs are ignored.
{
"192.168.1.2",
http.Header{
http.CanonicalHeaderKey("x-real-ip"): []string{"this-is-not-an-ip"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"11.12.13.14",
http.Header{
http.CanonicalHeaderKey("x-real-ip"): []string{"this-is-not-an-ip"},
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"11.12.13.14",
http.Header{
http.CanonicalHeaderKey("x-real-ip"): []string{"this-is-not-an-ip"},
http.CanonicalHeaderKey("x-forwarded-for"): []string{"11.12.13.14, 192.168.30.32, proxy1"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"192.168.1.2",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"this-is-not-an-ip"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
{
"192.168.2.3",
http.Header{
http.CanonicalHeaderKey("x-forwarded-for"): []string{"this-is-not-an-ip, 192.168.2.3"},
},
"192.168.0.0/16",
"192.168.1.2:23456",
},
}
for _, tc := range testcases {
trustedProxies, err := container.ParseIPList(tc.trusted)
if !assert.NoError(t, err, "invalid trusted proxies in %+v", tc) {
continue
}
request := &http.Request{
RemoteAddr: tc.addr,
Header: tc.headers,
}
assert.Equal(t, tc.expected, GetRealUserIP(request, trustedProxies), "failed for %+v", tc)
}
}

View file

@ -0,0 +1,60 @@
/**
* Standalone signaling server for the Nextcloud Spreed app.
* Copyright (C) 2021 struktur AG
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package client
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/strukturag/nextcloud-spreed-signaling/metrics"
)
var (
statsClientRTT = prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: "signaling",
Subsystem: "client",
Name: "rtt",
Help: "The roundtrip time of WebSocket ping messages in milliseconds",
Buckets: prometheus.ExponentialBucketsRange(1, 30000, 50),
})
statsClientBytesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "signaling",
Subsystem: "client",
Name: "bytes_total",
Help: "The total number of bytes sent to or received by clients",
}, []string{"direction"})
statsClientMessagesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "signaling",
Subsystem: "client",
Name: "messages_total",
Help: "The total number of messages sent to or received by clients",
}, []string{"direction"})
clientStats = []prometheus.Collector{
statsClientRTT,
statsClientBytesTotal,
statsClientMessagesTotal,
}
)
func RegisterClientStats() {
metrics.RegisterAll(clientStats...)
}