From e4523df602a0fa7a001730d8d3190e15d87cb94a Mon Sep 17 00:00:00 2001 From: savinmax Date: Sat, 2 Aug 2025 18:33:50 +0200 Subject: [PATCH] Init --- .gitea/workflows/ci.yml | 42 +++++++++++++++ .gitea/workflows/release.yml | 28 ++++++++++ .gitignore | 33 ++++++++++++ Makefile | 25 +++++++++ README.md | 33 ++++++++++++ config.example.yaml | 6 +++ example/index.html | 86 ++++++++++++++++++++++++++++++ go.mod | 23 ++++++++ go.sum | 47 ++++++++++++++++ internal/config/config.go | 32 +++++++++++ internal/config/config_test.go | 45 ++++++++++++++++ internal/hub/hub.go | 97 ++++++++++++++++++++++++++++++++++ internal/hub/hub_test.go | 40 ++++++++++++++ internal/metrics/metrics.go | 28 ++++++++++ main.go | 43 +++++++++++++++ 15 files changed, 608 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 config.example.yaml create mode 100644 example/index.html create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/hub/hub.go create mode 100644 internal/hub/hub_test.go create mode 100644 internal/metrics/metrics.go create mode 100644 main.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..0106392 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..4ee985f --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..236ad1a --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b6c9f2a --- /dev/null +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c03ec1 --- /dev/null +++ b/README.md @@ -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!'); +``` \ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..4d11b5c --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,6 @@ +server: + port: 8443 + tls: + enabled: true + cert_file: cert.pem + key_file: key.pem \ No newline at end of file diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..f2c23b1 --- /dev/null +++ b/example/index.html @@ -0,0 +1,86 @@ + + + + + + + Websocket relay example + + + + +
+

P2P Chat

+
+ +
+ +
+ + + + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f1e3f19 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b4c7dab --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..20eff8f --- /dev/null +++ b/internal/config/config.go @@ -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 +} \ No newline at end of file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0212ba3 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} \ No newline at end of file diff --git a/internal/hub/hub.go b/internal/hub/hub.go new file mode 100644 index 0000000..88c1d0c --- /dev/null +++ b/internal/hub/hub.go @@ -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) +} \ No newline at end of file diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go new file mode 100644 index 0000000..e979bdb --- /dev/null +++ b/internal/hub/hub_test.go @@ -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") + } +} \ No newline at end of file diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..c99e2bf --- /dev/null +++ b/internal/metrics/metrics.go @@ -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", + }) +) \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..a92bb75 --- /dev/null +++ b/main.go @@ -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)) + } +}