Init
Some checks failed
CI / test (push) Successful in 1m10s
CI / lint (push) Successful in 30s
Release / release (push) Failing after 31s

This commit is contained in:
savinmax 2025-08-02 18:33:50 +02:00
commit e4523df602
15 changed files with 608 additions and 0 deletions

42
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,42 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Install dependencies
run: go mod tidy
- name: Run tests
run: go test -v ./...
- name: Build
run: make build
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest

View File

@ -0,0 +1,28 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build release binaries
run: VERSION=${{ github.ref_name }} make release
- name: Upload Release Assets
uses: actions/upload-artifact@v4
with:
name: websocket-relay
path: build/*
overwrite: true

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
websocket-relay
build/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go workspace file
go.work
# SSL certificates
*.pem
*.key
*.crt
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Other
config.yaml

25
Makefile Normal file
View File

@ -0,0 +1,25 @@
.PHONY: build test release clean
BINARY_NAME=websocket-relay
VERSION?=1.0.0
build:
mkdir -p build
go build -o build/$(BINARY_NAME) .
test:
go test -v ./...
release:
mkdir -p build
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s -X main.version=$(VERSION)" -o build/$(BINARY_NAME)-linux-amd64 .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-w -s -X main.version=$(VERSION)" -o build/$(BINARY_NAME)-darwin-arm64 .
clean:
rm -rf build/$(BINARY_NAME)*
run:
go run .
deps:
go mod tidy

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# WebSocket Relay Server
A minimal Go WebSocket relay server with SSL support for P2P connections.
## Setup
```bash
go mod tidy
# Configure via config.yaml (see config.yaml for options)
go run main.go
```
## Configuration
Edit `config.yaml` to configure:
- **Server port and TLS settings**
- **SSL certificate paths**
## Usage
- WebSocket endpoint: `/ws`
- All WebSocket messages are relayed to all connected clients
## Testing
```javascript
// For TLS enabled (default config)
const ws = new WebSocket('wss://localhost:8443/ws');
// For HTTP only
// const ws = new WebSocket('ws://localhost:8443/ws');
ws.onmessage = (event) => console.log('Received:', event.data);
ws.send('Hello from client!');
```

6
config.example.yaml Normal file
View File

@ -0,0 +1,6 @@
server:
port: 8443
tls:
enabled: true
cert_file: cert.pem
key_file: key.pem

86
example/index.html Normal file
View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Websocket relay example</title>
</head>
<body>
<style>
.chat {
width: 600px;
height: 500px;
border: 1px solid black;
overflow-y: scroll;
background-color: beige;
padding: 15px;
box-sizing: content-box;
}
.chat .bubble {
width: calc(100% - 30px);
border-radius: 10px;
background-color: bisque;
font-size: 12px;
margin-bottom: 10px;
padding: 5px 10px;
}
.chat .error {
color: red;
margin: 10px 0;
}
</style>
<div>
<h1>P2P Chat</h1>
<div class="chat" id="box"></div>
<textarea id="message"></textarea>
<br />
<button id="send" disabled>Send</button>
</div>
<script>
const chat = document.getElementById("box");
const message = document.getElementById("message");
const btn = document.getElementById("send");
const name = Date.now().toString(36);
let retry = 1000;
let ws;
function connect() {
ws = new WebSocket('ws://localhost:8000/');
ws.onmessage = (event) => {
console.log('Received:', event.data);
chat.innerHTML += `<div class="bubble">${event.data}</div>`;
};
ws.onopen = () => {
btn.removeAttribute("disabled");
ws.send(`${name} joined the chat`);
};
ws.onerror = (ev) => {
btn.setAttribute("disabled", "disabled");
chat.innerHTML += `<div class="error">Failed to connect to websocket, retrying...</div>`;
console.error(ev);
delete ws.onmessage;
delete ws.onopen;
setTimeout(connect, retry);
retry *= 2;
};
}
btn.addEventListener("click", (ev) => {
ev.preventDefault();
const msg = message.value.trim();
if (!msg) {
return;
}
const data = `${name}<br>${msg}`;
ws.send(data);
message.value = "";
});
connect();
</script>
</body>
</html>

23
go.mod Normal file
View File

@ -0,0 +1,23 @@
module websocket-relay
go 1.21
require (
github.com/gorilla/websocket v1.5.1
github.com/prometheus/client_golang v1.17.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

47
go.sum Normal file
View File

@ -0,0 +1,47 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

32
internal/config/config.go Normal file
View File

@ -0,0 +1,32 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server struct {
Port int `yaml:"port"`
TLS struct {
Enabled bool `yaml:"enabled"`
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
} `yaml:"tls"`
} `yaml:"server"`
Metrics struct {
Enabled bool `yaml:"enabled"`
Port int `yaml:"port"`
} `yaml:"metrics"`
}
func Load(filename string) (*Config, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
var config Config
err = yaml.Unmarshal(data, &config)
return &config, err
}

View File

@ -0,0 +1,45 @@
package config
import (
"os"
"testing"
)
func TestLoad(t *testing.T) {
testConfig := `server:
port: 9000
tls:
enabled: false
cert_file: test.pem
key_file: test.key`
tmpFile, err := os.CreateTemp("", "config_test_*.yaml")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(testConfig); err != nil {
t.Fatal(err)
}
tmpFile.Close()
config, err := Load(tmpFile.Name())
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if config.Server.Port != 9000 {
t.Errorf("Expected port 9000, got %d", config.Server.Port)
}
if config.Server.TLS.Enabled != false {
t.Errorf("Expected TLS disabled, got %v", config.Server.TLS.Enabled)
}
}
func TestLoadFileNotFound(t *testing.T) {
_, err := Load("nonexistent.yaml")
if err == nil {
t.Error("Expected error for nonexistent file")
}
}

97
internal/hub/hub.go Normal file
View File

@ -0,0 +1,97 @@
package hub
import (
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
"websocket-relay/internal/metrics"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type Hub struct {
clients map[*websocket.Conn]bool
broadcast chan []byte
register chan *websocket.Conn
unregister chan *websocket.Conn
mu sync.RWMutex
}
func New() *Hub {
return &Hub{
clients: make(map[*websocket.Conn]bool),
broadcast: make(chan []byte),
register: make(chan *websocket.Conn),
unregister: make(chan *websocket.Conn),
}
}
func (h *Hub) Run() {
for {
select {
case conn := <-h.register:
h.mu.Lock()
h.clients[conn] = true
h.mu.Unlock()
metrics.ConnectedClients.Set(float64(len(h.clients)))
metrics.ConnectionsTotal.Inc()
log.Printf("Client connected. Total: %d", len(h.clients))
case conn := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[conn]; ok {
delete(h.clients, conn)
conn.Close()
}
h.mu.Unlock()
metrics.ConnectedClients.Set(float64(len(h.clients)))
metrics.DisconnectionsTotal.Inc()
log.Printf("Client disconnected. Total: %d", len(h.clients))
case message := <-h.broadcast:
metrics.MessagesTotal.Inc()
h.mu.RLock()
for conn := range h.clients {
if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
delete(h.clients, conn)
conn.Close()
}
}
h.mu.RUnlock()
}
}
}
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
h.register <- conn
go func() {
defer func() {
h.unregister <- conn
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
h.broadcast <- message
}
}()
}
func (h *Hub) ClientCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}

40
internal/hub/hub_test.go Normal file
View File

@ -0,0 +1,40 @@
package hub
import (
"testing"
"time"
)
func TestNew(t *testing.T) {
h := New()
if h == nil {
t.Fatal("New returned nil")
}
if h.clients == nil {
t.Error("clients map not initialized")
}
if h.broadcast == nil {
t.Error("broadcast channel not initialized")
}
}
func TestClientCount(t *testing.T) {
h := New()
go h.Run()
if count := h.ClientCount(); count != 0 {
t.Errorf("Expected 0 clients, got %d", count)
}
}
func TestBroadcastChannel(t *testing.T) {
h := New()
go h.Run()
select {
case h.broadcast <- []byte("test"):
// Channel is working
case <-time.After(100 * time.Millisecond):
t.Error("broadcast channel blocked")
}
}

View File

@ -0,0 +1,28 @@
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
ConnectedClients = promauto.NewGauge(prometheus.GaugeOpts{
Name: "websocket_connected_clients",
Help: "Number of currently connected WebSocket clients",
})
MessagesTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "websocket_messages_total",
Help: "Total number of WebSocket messages processed",
})
ConnectionsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "websocket_connections_total",
Help: "Total number of WebSocket connections established",
})
DisconnectionsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "websocket_disconnections_total",
Help: "Total number of WebSocket disconnections",
})
)

43
main.go Normal file
View File

@ -0,0 +1,43 @@
package main
import (
"fmt"
"log"
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
"websocket-relay/internal/config"
"websocket-relay/internal/hub"
)
func main() {
cfg, err := config.Load("config.yaml")
if err != nil {
log.Fatal("Failed to load config:", err)
}
h := hub.New()
go h.Run()
// Start metrics server if enabled
if cfg.Metrics.Enabled {
go func() {
metricsAddr := fmt.Sprintf(":%d", cfg.Metrics.Port)
log.Printf("Metrics server starting on %s", metricsAddr)
http.Handle("/metrics", promhttp.Handler())
log.Fatal(http.ListenAndServe(metricsAddr, nil))
}()
}
mux := http.NewServeMux()
mux.HandleFunc("/ws", h.HandleWebSocket)
addr := fmt.Sprintf(":%d", cfg.Server.Port)
if cfg.Server.TLS.Enabled {
log.Printf("WebSocket relay server starting on %s (TLS)", addr)
log.Fatal(http.ListenAndServeTLS(addr, cfg.Server.TLS.CertFile, cfg.Server.TLS.KeyFile, mux))
} else {
log.Printf("WebSocket relay server starting on %s (HTTP)", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
}