├── install ├── chat-server.service └── docker-entrypoint.sh ├── internal ├── server │ ├── config.go │ └── server.go ├── chat │ ├── client_test.go │ ├── room_test.go │ ├── room.go │ └── client.go └── ui │ └── styles.go ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── agent.md ├── .github └── workflows │ ├── test-build.yml │ └── docker-publish.yml ├── CLAUDE.md ├── README.md ├── cmd └── chat-tails │ └── main.go ├── go.mod └── go.sum /install/chat-server.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Terminal Chat Server 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=nobody 8 | ExecStart=/usr/local/bin/chat-server 9 | Restart=on-failure 10 | RestartSec=5 11 | Environment=PORT=2323 12 | Environment=ROOM_NAME=Chat Room 13 | Environment=MAX_USERS=10 14 | 15 | [Install] 16 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /internal/server/config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | // Config holds the server configuration 4 | type Config struct { 5 | Port int // TCP port to listen on 6 | RoomName string // Chat room name 7 | MaxUsers int // Maximum allowed users 8 | EnableTailscale bool // Whether to enable Tailscale mode 9 | HostName string // Tailscale hostname (only used if EnableTailscale is true) 10 | EnableHistory bool // Whether to enable message history for new users 11 | HistorySize int // Number of messages to keep in history 12 | } -------------------------------------------------------------------------------- /install/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Check if TS_AUTHKEY is provided 5 | if [ -n "$TS_AUTHKEY" ]; then 6 | # If TS_AUTHKEY is provided, enable Tailscale mode 7 | echo "Starting in Tailscale mode with hostname: ${HOSTNAME:-chatroom}" 8 | exec ./chat-server --tailscale --hostname "${HOSTNAME:-chatroom}" --port "$PORT" --room-name "$ROOM_NAME" --max-users "$MAX_USERS" "$@" 9 | else 10 | # Otherwise, start in regular TCP mode 11 | echo "Starting in regular TCP mode on port: $PORT" 12 | exec ./chat-server --port "$PORT" --room-name "$ROOM_NAME" --max-users "$MAX_USERS" "$@" 13 | fi -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.claude/settings.local.json 2 | chat-server 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | # IDE specific files 28 | .idea/ 29 | *.swp 30 | *.swo 31 | *~ 32 | .vscode/ 33 | *.iml 34 | 35 | # OS specific files 36 | .DS_Store 37 | Thumbs.db 38 | 39 | # Build directories 40 | dist/ 41 | build/ 42 | bin/ 43 | 44 | # Coverage files 45 | coverage.txt 46 | coverage.html 47 | *.cover 48 | *.coverprofile -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | # Build arguments for version info 6 | ARG GIT_TAG=dev 7 | ARG GIT_COMMIT=unknown 8 | 9 | # Copy go.mod and go.sum 10 | COPY go.mod go.sum ./ 11 | 12 | # Download dependencies 13 | RUN go mod download 14 | 15 | # Copy source code 16 | COPY . . 17 | 18 | # Build the application with version info 19 | RUN CGO_ENABLED=0 GOOS=linux go build \ 20 | -ldflags="-X main.Version=${GIT_TAG} -X main.Commit=${GIT_COMMIT}" \ 21 | -o chat-server ./cmd/chat-tails 22 | 23 | # Create final lightweight image 24 | FROM alpine:latest 25 | 26 | WORKDIR /app 27 | 28 | # Copy the binary from the builder stage 29 | COPY --from=builder /app/chat-server . 30 | 31 | # Set environment variables 32 | ENV PORT=2323 \ 33 | ROOM_NAME="Chat Room" \ 34 | MAX_USERS=10 \ 35 | TS_AUTHKEY="" 36 | 37 | # Expose the port 38 | EXPOSE ${PORT} 39 | 40 | # Run the application with a custom entrypoint script 41 | COPY --from=builder /app/install/docker-entrypoint.sh /docker-entrypoint.sh 42 | RUN chmod +x /docker-entrypoint.sh 43 | 44 | ENTRYPOINT ["/docker-entrypoint.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Brian Scott 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build run clean test docker-build docker-run 2 | 3 | # Binary output 4 | BINARY_NAME=chat-server 5 | 6 | # Build the application 7 | build: 8 | go build -o $(BINARY_NAME) ./cmd/chat-tails 9 | 10 | # Run the application 11 | run: build 12 | ./$(BINARY_NAME) 13 | 14 | # Clean build artifacts 15 | clean: 16 | rm -f $(BINARY_NAME) 17 | 18 | # Run tests 19 | test: 20 | go test -v ./internal/chat/ 21 | 22 | # Build Docker image 23 | docker-build: 24 | docker build -t chat-server . 25 | 26 | # Run Docker container 27 | docker-run: docker-build 28 | docker run -p 2323:2323 chat-server 29 | 30 | # Cross-compile for different platforms 31 | build-all: build-linux build-macos build-windows build-arm 32 | 33 | # Linux amd64 34 | build-linux: 35 | GOOS=linux GOARCH=amd64 go build -o $(BINARY_NAME)-linux-amd64 ./cmd/chat-tails 36 | 37 | # macOS amd64 38 | build-macos: 39 | GOOS=darwin GOARCH=amd64 go build -o $(BINARY_NAME)-darwin-amd64 ./cmd/chat-tails 40 | 41 | # Windows amd64 42 | build-windows: 43 | GOOS=windows GOARCH=amd64 go build -o $(BINARY_NAME)-windows-amd64.exe ./cmd/chat-tails 44 | 45 | # ARM (Raspberry Pi) 46 | build-arm: 47 | GOOS=linux GOARCH=arm go build -o $(BINARY_NAME)-linux-arm ./cmd/chat-tails -------------------------------------------------------------------------------- /internal/chat/client_test.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestClientConstants(t *testing.T) { 9 | // Test that constants are set to reasonable values 10 | if MaxMessageLength <= 0 { 11 | t.Errorf("MaxMessageLength should be positive, got %d", MaxMessageLength) 12 | } 13 | 14 | if MaxMessageLength > 10000 { 15 | t.Errorf("MaxMessageLength seems too large: %d", MaxMessageLength) 16 | } 17 | 18 | if MessageRateLimit <= 0 { 19 | t.Errorf("MessageRateLimit should be positive, got %d", MessageRateLimit) 20 | } 21 | 22 | if RateLimitWindow <= 0 { 23 | t.Errorf("RateLimitWindow should be positive, got %v", RateLimitWindow) 24 | } 25 | } 26 | 27 | func TestMessageValidation(t *testing.T) { 28 | // Test message length constants 29 | tests := []struct { 30 | name string 31 | messageLen int 32 | shouldBeOK bool 33 | }{ 34 | {"short message", 10, true}, 35 | {"medium message", 500, true}, 36 | {"max length", MaxMessageLength, true}, 37 | {"over max", MaxMessageLength + 1, false}, 38 | } 39 | 40 | for _, tt := range tests { 41 | t.Run(tt.name, func(t *testing.T) { 42 | msg := strings.Repeat("a", tt.messageLen) 43 | if tt.shouldBeOK && len(msg) > MaxMessageLength { 44 | t.Errorf("Message of length %d should be valid but exceeds MaxMessageLength", tt.messageLen) 45 | } 46 | if !tt.shouldBeOK && len(msg) <= MaxMessageLength { 47 | t.Errorf("Message of length %d should be invalid but is within MaxMessageLength", tt.messageLen) 48 | } 49 | }) 50 | } 51 | } -------------------------------------------------------------------------------- /agent.md: -------------------------------------------------------------------------------- 1 | # Agent Guidelines 2 | 3 | ## Project Context 4 | 5 | Terminal chat server in Go with optional Tailscale networking. Users connect via telnet/netcat. 6 | 7 | ## Quick Reference 8 | 9 | | Task | Command | 10 | |------|---------| 11 | | Build | `make build` | 12 | | Run | `make run` or `./chat-server` | 13 | | Test | `make test` | 14 | | Single test | `go test -v -run TestName ./internal/chat/` | 15 | 16 | ## Code Locations 17 | 18 | - Entry point: `cmd/chat-tails/main.go` 19 | - Server lifecycle: `internal/server/server.go` 20 | - Chat room logic: `internal/chat/room.go` 21 | - Client handling: `internal/chat/client.go` 22 | - UI styling: `internal/ui/styles.go` 23 | 24 | ## Implementation Notes 25 | 26 | ### Adding New Chat Commands 27 | 28 | 1. Add case to `handleCommand()` in `internal/chat/client.go` 29 | 2. Update help text in `internal/ui/styles.go:FormatHelp()` 30 | 31 | ### Modifying Room Behavior 32 | 33 | Room uses channel-based event loop in `run()`. Client map operations go through `join`/`leave` channels to avoid races. Don't access `r.clients` directly outside of `addClient`/`removeClient`. 34 | 35 | ### Adding New Config Options 36 | 37 | 1. Add field to `internal/server/config.go:Config` 38 | 2. Add flag in `cmd/chat-tails/main.go:parseFlags()` 39 | 3. Use in `internal/server/server.go` 40 | 41 | ## Testing 42 | 43 | Tests exist for `internal/chat/` package. Run with `make test` or target specific tests: 44 | 45 | ```bash 46 | go test -v -run TestRoom ./internal/chat/ 47 | go test -v -run TestClient ./internal/chat/ 48 | ``` 49 | 50 | ## Dependencies 51 | 52 | - `github.com/charmbracelet/lipgloss` - Terminal styling 53 | - `github.com/spf13/pflag` - CLI flags 54 | - `tailscale.com/tsnet` - Tailscale integration 55 | -------------------------------------------------------------------------------- /.github/workflows/test-build.yml: -------------------------------------------------------------------------------- 1 | name: Test and Build 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | paths: 7 | - '**.go' 8 | - 'go.mod' 9 | - 'go.sum' 10 | - 'Dockerfile' 11 | - '.github/workflows/**' 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test-build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: '1.24' 31 | cache: true 32 | 33 | - name: Download dependencies 34 | run: go mod download 35 | 36 | - name: Verify dependencies 37 | run: go mod verify 38 | 39 | - name: Build 40 | run: go build -v ./cmd/chat-tails 41 | 42 | - name: Run tests 43 | run: go test -v ./... 44 | 45 | - name: Get version info 46 | id: version 47 | run: | 48 | echo "git_tag=${GITHUB_REF_NAME:-dev}" >> $GITHUB_OUTPUT 49 | echo "git_commit=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 50 | 51 | - name: Set up QEMU 52 | uses: docker/setup-qemu-action@v3 53 | 54 | - name: Set up Docker Buildx 55 | uses: docker/setup-buildx-action@v3 56 | 57 | - name: Test Docker build 58 | uses: docker/build-push-action@v6 59 | with: 60 | context: . 61 | platforms: linux/amd64,linux/arm64 62 | push: false 63 | build-args: | 64 | GIT_TAG=${{ steps.version.outputs.git_tag }} 65 | GIT_COMMIT=${{ steps.version.outputs.git_commit }} 66 | cache-from: type=gha 67 | cache-to: type=gha,mode=max 68 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Build & Development Commands 6 | 7 | ```bash 8 | # Build the binary 9 | make build # or: go build -o chat-server ./cmd/ts-chat 10 | 11 | # Run the server 12 | make run # builds and runs 13 | ./chat-server # run directly 14 | 15 | # Run tests 16 | make test # runs: go test -v ./internal/chat/ 17 | 18 | # Run a single test 19 | go test -v -run TestName ./internal/chat/ 20 | 21 | # Cross-compile 22 | make build-all # builds for linux, macos, windows, arm 23 | ``` 24 | 25 | ## Architecture 26 | 27 | This is a terminal-based chat server written in Go that supports both regular TCP mode and Tailscale networking. 28 | 29 | ### Package Structure 30 | 31 | - `cmd/chat-tails/main.go` - Entry point, CLI flag parsing with spf13/pflag 32 | - `internal/server/` - Server lifecycle (start/stop), connection handling, Tailscale integration via tsnet 33 | - `internal/chat/` - Core chat logic: 34 | - `room.go` - Room manages clients via channels (join/leave/broadcast pattern) 35 | - `client.go` - Client handles per-connection I/O, commands, rate limiting 36 | - `internal/ui/` - Terminal styling using charmbracelet/lipgloss 37 | 38 | ### Key Patterns 39 | 40 | **Room event loop** (`room.go:run`): Uses channel-based concurrency with `join`, `leave`, and `broadcast` channels processed in a single goroutine to avoid race conditions on the client map. 41 | 42 | **Client handling** (`client.go:Handle`): Uses goroutine-based reader with context cancellation for clean shutdown. Rate limiting uses sliding window (5 messages per 5 seconds). 43 | 44 | **Connection modes**: Regular TCP (`net.Listen`) or Tailscale (`tsnet.Server.Listen`) based on `--tailscale` flag. Tailscale auth via `TS_AUTHKEY` env var. 45 | 46 | ### Chat Commands 47 | 48 | `/who`, `/me `, `/help`, `/quit` - implemented in `client.go:handleCommand` 49 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | REGISTRY: ghcr.io 15 | IMAGE_NAME: ${{ github.repository }} 16 | 17 | permissions: 18 | contents: write 19 | packages: write 20 | 21 | jobs: 22 | build-and-push: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v3 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Log in to GitHub Container Registry 35 | uses: docker/login-action@v3 36 | with: 37 | registry: ${{ env.REGISTRY }} 38 | username: ${{ github.actor }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - name: Extract metadata for Docker 42 | id: meta 43 | uses: docker/metadata-action@v5 44 | with: 45 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 46 | tags: | 47 | type=semver,pattern={{version}} 48 | type=semver,pattern={{major}}.{{minor}} 49 | type=semver,pattern={{major}} 50 | type=sha,prefix= 51 | type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref, '-') }} 52 | 53 | - name: Get version info 54 | id: version 55 | run: | 56 | echo "git_tag=${GITHUB_REF_NAME:-dev}" >> $GITHUB_OUTPUT 57 | echo "git_commit=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT 58 | 59 | - name: Build and push Docker image 60 | uses: docker/build-push-action@v6 61 | with: 62 | context: . 63 | platforms: linux/amd64,linux/arm64 64 | push: true 65 | tags: ${{ steps.meta.outputs.tags }} 66 | labels: ${{ steps.meta.outputs.labels }} 67 | build-args: | 68 | GIT_TAG=${{ steps.version.outputs.git_tag }} 69 | GIT_COMMIT=${{ steps.version.outputs.git_commit }} 70 | cache-from: type=gha 71 | cache-to: type=gha,mode=max 72 | 73 | - name: Create GitHub Release 74 | if: github.ref_type == 'tag' 75 | uses: softprops/action-gh-release@v2 76 | with: 77 | generate_release_notes: true 78 | make_latest: ${{ !contains(github.ref, '-') }} 79 | -------------------------------------------------------------------------------- /internal/chat/room_test.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestNewRoom(t *testing.T) { 9 | room := NewRoom("Test Room", 10, false, 0) 10 | defer room.Stop() 11 | 12 | if room.Name != "Test Room" { 13 | t.Errorf("Expected room name 'Test Room', got %s", room.Name) 14 | } 15 | 16 | if room.MaxUsers != 10 { 17 | t.Errorf("Expected max users 10, got %d", room.MaxUsers) 18 | } 19 | 20 | if room.clients == nil { 21 | t.Error("Expected clients map to be initialized") 22 | } 23 | 24 | if room.broadcast == nil { 25 | t.Error("Expected broadcast channel to be initialized") 26 | } 27 | 28 | if room.join == nil { 29 | t.Error("Expected join channel to be initialized") 30 | } 31 | 32 | if room.leave == nil { 33 | t.Error("Expected leave channel to be initialized") 34 | } 35 | } 36 | 37 | func TestRoomStop(t *testing.T) { 38 | room := NewRoom("Test Room", 10, false, 0) 39 | 40 | // Give room time to start 41 | time.Sleep(10 * time.Millisecond) 42 | 43 | // Stop the room 44 | err := room.Stop() 45 | if err != nil { 46 | t.Errorf("Failed to stop room: %v", err) 47 | } 48 | 49 | // Check that done channel is closed 50 | select { 51 | case <-room.done: 52 | // Successfully closed 53 | case <-time.After(100 * time.Millisecond): 54 | t.Error("Room done channel not closed after Stop()") 55 | } 56 | } 57 | 58 | func TestMessageStruct(t *testing.T) { 59 | now := time.Now() 60 | msg := Message{ 61 | From: "Alice", 62 | Content: "Hello", 63 | Timestamp: now, 64 | IsSystem: true, 65 | IsAction: false, 66 | } 67 | 68 | if msg.From != "Alice" { 69 | t.Errorf("Expected From='Alice', got %s", msg.From) 70 | } 71 | 72 | if msg.Content != "Hello" { 73 | t.Errorf("Expected Content='Hello', got %s", msg.Content) 74 | } 75 | 76 | if !msg.IsSystem { 77 | t.Error("Expected IsSystem=true") 78 | } 79 | 80 | if msg.IsAction { 81 | t.Error("Expected IsAction=false") 82 | } 83 | } 84 | 85 | func TestRoomChannels(t *testing.T) { 86 | room := NewRoom("Test Room", 5, false, 0) 87 | defer room.Stop() 88 | 89 | // Test that channels are properly initialized 90 | if cap(room.broadcast) != 0 { 91 | t.Errorf("Expected unbuffered broadcast channel, got capacity %d", cap(room.broadcast)) 92 | } 93 | 94 | if cap(room.join) != 0 { 95 | t.Errorf("Expected unbuffered join channel, got capacity %d", cap(room.join)) 96 | } 97 | 98 | if cap(room.leave) != 0 { 99 | t.Errorf("Expected unbuffered leave channel, got capacity %d", cap(room.leave)) 100 | } 101 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chat Tails 2 | 3 | A terminal-based chat application built in Go. Share a chat room with friends over your Tailscale network - they connect via netcat or telnet, no client installation needed. 4 | 5 | ## Features 6 | 7 | - **Tailscale Integration** - Share your chat server securely with anyone on your Tailnet 8 | - **Zero Client Setup** - Users connect with just `nc` or `telnet` 9 | - **Colorful UI** - Each user gets a unique color, styled messages with ANSI colors 10 | - **Message History** - New users can see recent chat history (optional) 11 | - **Chat Commands** - `/who`, `/me`, `/help`, `/quit` 12 | - **Rate Limiting** - Built-in protection against spam 13 | 14 | ## Quick Start 15 | 16 | ```bash 17 | # Build 18 | make build 19 | 20 | # Run locally 21 | ./chat-server 22 | 23 | # Run with Tailscale (share with your network) 24 | export TS_AUTHKEY=tskey-auth-xxxxx 25 | ./chat-server --tailscale --hostname mychat --history 26 | ``` 27 | 28 | Connect from any machine: 29 | ```bash 30 | nc mychat.your-tailnet.ts.net 2323 31 | ``` 32 | 33 | ## Installation 34 | 35 | ### From Source 36 | 37 | ```bash 38 | git clone https://github.com/bscott/chat-tails.git 39 | cd chat-tails 40 | make build 41 | ``` 42 | 43 | ### Docker 44 | 45 | ```bash 46 | # Build 47 | docker build -t chat-tails . 48 | 49 | # Run locally 50 | docker run -p 2323:2323 chat-tails 51 | 52 | # Run with Tailscale 53 | docker run -e TS_AUTHKEY=tskey-auth-xxxxx chat-tails --tailscale --hostname mychat 54 | ``` 55 | 56 | ## Configuration 57 | 58 | | Flag | Short | Default | Description | 59 | |------|-------|---------|-------------| 60 | | `--port` | `-p` | 2323 | TCP port to listen on | 61 | | `--room-name` | `-r` | "Chat Room" | Name displayed in the chat | 62 | | `--max-users` | `-m` | 10 | Maximum concurrent users | 63 | | `--tailscale` | `-t` | false | Enable Tailscale mode | 64 | | `--hostname` | `-H` | "chatroom" | Tailscale hostname (requires `--tailscale`) | 65 | | `--history` | | false | Enable message history for new users | 66 | | `--history-size` | | 50 | Number of messages to keep in history | 67 | 68 | ## Tailscale Setup 69 | 70 | 1. Get an auth key from [Tailscale Admin Console](https://login.tailscale.com/admin/settings/keys) 71 | 2. Set the environment variable: 72 | ```bash 73 | export TS_AUTHKEY=tskey-auth-xxxxx 74 | ``` 75 | 3. Run with Tailscale enabled: 76 | ```bash 77 | ./chat-server --tailscale --hostname mychat 78 | ``` 79 | 4. Share with others on your Tailnet - they connect with: 80 | ```bash 81 | nc mychat.your-tailnet.ts.net 2323 82 | ``` 83 | 84 | ### Troubleshooting 85 | 86 | If you see "Authkey is set; but state is NoState": 87 | ```bash 88 | # Option 1: Force new login 89 | export TSNET_FORCE_LOGIN=1 90 | 91 | # Option 2: Clear existing state 92 | rm -rf ~/Library/Application\ Support/tsnet-chat-server/ # macOS 93 | rm -rf ~/.local/share/tsnet-chat-server/ # Linux 94 | ``` 95 | 96 | ## Chat Commands 97 | 98 | | Command | Description | 99 | |---------|-------------| 100 | | `/who` | List all users in the room | 101 | | `/me ` | Send an action (e.g., `/me waves` → `* Brian waves`) | 102 | | `/help` | Show available commands | 103 | | `/quit` | Disconnect from chat | 104 | 105 | ## Development 106 | 107 | ```bash 108 | # Build 109 | make build 110 | 111 | # Run tests 112 | make test 113 | 114 | # Run a single test 115 | go test -v -run TestName ./internal/chat/ 116 | 117 | # Cross-compile for all platforms 118 | make build-all 119 | ``` 120 | 121 | ### Project Structure 122 | 123 | ``` 124 | ├── cmd/chat-tails/ # Application entry point 125 | ├── internal/ 126 | │ ├── chat/ # Room and client handling 127 | │ ├── server/ # Server lifecycle, Tailscale integration 128 | │ └── ui/ # Terminal styling (lipgloss) 129 | └── Makefile 130 | ``` 131 | 132 | ## License 133 | 134 | MIT 135 | -------------------------------------------------------------------------------- /cmd/chat-tails/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/bscott/ts-chat/internal/server" 11 | "github.com/spf13/pflag" 12 | ) 13 | 14 | // Version info - set by ldflags at build time 15 | var ( 16 | Version = "dev" 17 | Commit = "unknown" 18 | ) 19 | 20 | // Default configuration values 21 | const ( 22 | defaultPort = 2323 23 | defaultRoomName = "Chat Room" 24 | defaultMaxUsers = 10 25 | defaultHostname = "chatroom" 26 | defaultHistorySize = 50 27 | ) 28 | 29 | type config struct { 30 | Port int 31 | RoomName string 32 | MaxUsers int 33 | EnableTailscale bool 34 | HostName string 35 | EnableHistory bool 36 | HistorySize int 37 | } 38 | 39 | func main() { 40 | // Parse command-line flags 41 | cfg, showVersion := parseFlags() 42 | 43 | // Handle --version flag 44 | if showVersion { 45 | fmt.Printf("Chat Tails %s (commit: %s)\n", Version, Commit) 46 | os.Exit(0) 47 | } 48 | 49 | // Setup logger 50 | log.SetPrefix("[chat-tails] ") 51 | 52 | log.Printf("Chat Tails %s (commit: %s)", Version, Commit) 53 | 54 | if cfg.EnableTailscale { 55 | log.Printf("Starting with hostname: %s, port: %d", cfg.HostName, cfg.Port) 56 | 57 | // Check for auth key 58 | if os.Getenv("TS_AUTHKEY") == "" { 59 | log.Println("Warning: TS_AUTHKEY environment variable not set. Tailscale mode may not work properly.") 60 | log.Println("Set TS_AUTHKEY=tskey-... to authenticate with Tailscale") 61 | } 62 | } else { 63 | log.Printf("Starting Chat Tails on port: %d", cfg.Port) 64 | } 65 | 66 | // Create and start the chat server 67 | chatServer, err := server.NewServer(server.Config{ 68 | Port: cfg.Port, 69 | RoomName: cfg.RoomName, 70 | MaxUsers: cfg.MaxUsers, 71 | EnableTailscale: cfg.EnableTailscale, 72 | HostName: cfg.HostName, 73 | EnableHistory: cfg.EnableHistory, 74 | HistorySize: cfg.HistorySize, 75 | }) 76 | if err != nil { 77 | log.Fatalf("Failed to create server: %v", err) 78 | } 79 | 80 | // Start the server 81 | go func() { 82 | if err := chatServer.Start(); err != nil { 83 | log.Fatalf("Server error: %v", err) 84 | } 85 | }() 86 | 87 | if cfg.EnableTailscale { 88 | log.Printf("Chat server started. Users can connect via: telnet %s.ts.net %d", cfg.HostName, cfg.Port) 89 | } else { 90 | log.Printf("Chat server started. Users can connect via: telnet localhost %d", cfg.Port) 91 | } 92 | 93 | log.Print("Press Ctrl+C to stop the server") 94 | 95 | // Wait for interrupt signal 96 | sigCh := make(chan os.Signal, 1) 97 | signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 98 | <-sigCh 99 | 100 | log.Print("Shutting down server...") 101 | if err := chatServer.Stop(); err != nil { 102 | log.Printf("Error shutting down server: %v", err) 103 | } 104 | os.Exit(0) 105 | } 106 | 107 | func parseFlags() (config, bool) { 108 | var cfg config 109 | var showVersion bool 110 | 111 | // Define command-line flags 112 | pflag.IntVarP(&cfg.Port, "port", "p", defaultPort, "TCP port to listen on") 113 | pflag.StringVarP(&cfg.RoomName, "room-name", "r", defaultRoomName, "Chat room name") 114 | pflag.IntVarP(&cfg.MaxUsers, "max-users", "m", defaultMaxUsers, "Maximum allowed users") 115 | pflag.BoolVarP(&cfg.EnableTailscale, "tailscale", "t", false, "Enable Tailscale mode") 116 | pflag.StringVarP(&cfg.HostName, "hostname", "H", defaultHostname, "Tailscale hostname (only used if --tailscale is enabled)") 117 | pflag.BoolVar(&cfg.EnableHistory, "history", false, "Enable message history for new users") 118 | pflag.IntVar(&cfg.HistorySize, "history-size", defaultHistorySize, "Number of messages to keep in history") 119 | pflag.BoolVarP(&showVersion, "version", "v", false, "Show version information") 120 | 121 | // Display help message 122 | pflag.Usage = func() { 123 | fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0]) 124 | fmt.Fprintf(os.Stderr, "Options:\n") 125 | pflag.PrintDefaults() 126 | } 127 | 128 | pflag.Parse() 129 | return cfg, showVersion 130 | } -------------------------------------------------------------------------------- /internal/ui/styles.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | // Color definitions 10 | var ( 11 | subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} 12 | highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} 13 | special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#2B5F3A"} 14 | accent = lipgloss.AdaptiveColor{Light: "#1D9BF0", Dark: "#1D9BF0"} 15 | warning = lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"} 16 | ) 17 | 18 | // UserColors is a list of colors for different users 19 | var UserColors = []string{ 20 | "#1D9BF0", // Blue 21 | "#F25D94", // Pink 22 | "#43BF6D", // Green 23 | "#FF6B35", // Orange 24 | "#9B5DE5", // Purple 25 | "#00F5D4", // Cyan 26 | "#FEE440", // Yellow 27 | "#FF595E", // Red 28 | } 29 | 30 | // GetUserColor returns a consistent color for a username based on hash 31 | func GetUserColor(username string) string { 32 | hash := 0 33 | for _, c := range username { 34 | hash = int(c) + ((hash << 5) - hash) 35 | } 36 | if hash < 0 { 37 | hash = -hash 38 | } 39 | return UserColors[hash%len(UserColors)] 40 | } 41 | 42 | // Style definitions 43 | var ( 44 | // Base styles 45 | BaseStyle = lipgloss.NewStyle(). 46 | BorderStyle(lipgloss.RoundedBorder()). 47 | BorderForeground(subtle) 48 | 49 | // Headers 50 | HeaderStyle = lipgloss.NewStyle(). 51 | Bold(true). 52 | Foreground(highlight). 53 | Padding(0, 1) 54 | 55 | // Text styles 56 | SystemStyle = lipgloss.NewStyle(). 57 | Foreground(special). 58 | Bold(true) 59 | 60 | UserStyle = lipgloss.NewStyle(). 61 | Foreground(accent). 62 | Bold(true) 63 | 64 | SelfStyle = lipgloss.NewStyle(). 65 | Foreground(highlight). 66 | Bold(true) 67 | 68 | ActionStyle = lipgloss.NewStyle(). 69 | Foreground(warning). 70 | Italic(true) 71 | 72 | // UI components 73 | BoxStyle = lipgloss.NewStyle(). 74 | Border(lipgloss.RoundedBorder()). 75 | BorderForeground(subtle). 76 | Padding(0, 1) 77 | 78 | InputStyle = lipgloss.NewStyle(). 79 | Border(lipgloss.RoundedBorder()). 80 | BorderForeground(highlight). 81 | Padding(0, 1) 82 | ) 83 | 84 | // FormatSystemMessage formats a system message 85 | func FormatSystemMessage(message string) string { 86 | return SystemStyle.Render("[System] " + message) 87 | } 88 | 89 | // FormatUserMessage formats a user message 90 | func FormatUserMessage(username, message, timestamp string) string { 91 | userColor := GetUserColor(username) 92 | style := lipgloss.NewStyle().Foreground(lipgloss.Color(userColor)).Bold(true) 93 | return style.Render("["+timestamp+"] "+username+": ") + message 94 | } 95 | 96 | // FormatSelfMessage formats the user's own message 97 | func FormatSelfMessage(message, timestamp string) string { 98 | return SelfStyle.Render("["+timestamp+"] You: ") + message 99 | } 100 | 101 | // FormatActionMessage formats an action message 102 | func FormatActionMessage(username, action string) string { 103 | userColor := GetUserColor(username) 104 | style := lipgloss.NewStyle().Foreground(lipgloss.Color(userColor)).Italic(true) 105 | return style.Render("* " + username + " " + action) 106 | } 107 | 108 | // FormatTitle formats a title 109 | func FormatTitle(title string) string { 110 | return HeaderStyle.Render("=== " + title + " ===") 111 | } 112 | 113 | // CreateColoredBox creates a colored box with a title and content 114 | func CreateColoredBox(title, content string, width int) string { 115 | box := BoxStyle.Copy().Width(width) 116 | return box.Render( 117 | HeaderStyle.Render(title) + "\n\n" + 118 | content, 119 | ) 120 | } 121 | 122 | // FormatHelp formats the help message 123 | func FormatHelp() string { 124 | return BoxStyle.Render( 125 | HeaderStyle.Render("Available Commands:") + "\n" + 126 | "/who - Show all users in the room\n" + 127 | "/me - Perform an action\n" + 128 | "/help - Show this help message\n" + 129 | "/quit - Leave the chat", 130 | ) 131 | } 132 | 133 | // FormatUserList formats the user list 134 | func FormatUserList(roomName string, users []string, maxUsers int) string { 135 | content := HeaderStyle.Render("Users in "+roomName+" ("+lipgloss.NewStyle().Foreground(accent).Render(fmt.Sprintf("%d/%d", len(users), maxUsers))+"):") + "\n" 136 | 137 | for _, user := range users { 138 | userColor := GetUserColor(user) 139 | style := lipgloss.NewStyle().Foreground(lipgloss.Color(userColor)).Bold(true) 140 | content += "- " + style.Render(user) + "\n" 141 | } 142 | 143 | return BoxStyle.Render(content) 144 | } 145 | 146 | // FormatWelcomeMessage formats the welcome message 147 | func FormatWelcomeMessage(roomName, nickname string) string { 148 | return HeaderStyle.Render("Welcome to "+roomName+", "+nickname+"!") + "\n\n" + 149 | "Type a message and press Enter to send. Use /help to see available commands." 150 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bscott/ts-chat 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/charmbracelet/lipgloss v0.9.1 9 | github.com/spf13/pflag v1.0.5 10 | tailscale.com v1.82.5 11 | ) 12 | 13 | require ( 14 | filippo.io/edwards25519 v1.1.0 // indirect 15 | github.com/akutz/memconn v0.1.0 // indirect 16 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 17 | github.com/aws/aws-sdk-go-v2 v1.36.0 // indirect 18 | github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect 19 | github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect 20 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect 21 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 // indirect 22 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 // indirect 23 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect 29 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 // indirect 30 | github.com/aws/smithy-go v1.22.2 // indirect 31 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 32 | github.com/coder/websocket v1.8.12 // indirect 33 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect 34 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect 35 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect 36 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 37 | github.com/gaissmai/bart v0.18.0 // indirect 38 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect 39 | github.com/go-ole/go-ole v1.3.0 // indirect 40 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 41 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 42 | github.com/google/btree v1.1.2 // indirect 43 | github.com/google/go-cmp v0.6.0 // indirect 44 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect 45 | github.com/google/uuid v1.6.0 // indirect 46 | github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect 47 | github.com/gorilla/securecookie v1.1.2 // indirect 48 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 49 | github.com/illarion/gonotify/v3 v3.0.2 // indirect 50 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect 51 | github.com/jmespath/go-jmespath v0.4.0 // indirect 52 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect 53 | github.com/klauspost/compress v1.17.11 // indirect 54 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect 55 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 56 | github.com/mattn/go-isatty v0.0.20 // indirect 57 | github.com/mattn/go-runewidth v0.0.15 // indirect 58 | github.com/mdlayher/genetlink v1.3.2 // indirect 59 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 60 | github.com/mdlayher/sdnotify v1.0.0 // indirect 61 | github.com/mdlayher/socket v0.5.0 // indirect 62 | github.com/miekg/dns v1.1.58 // indirect 63 | github.com/mitchellh/go-ps v1.0.0 // indirect 64 | github.com/muesli/reflow v0.3.0 // indirect 65 | github.com/muesli/termenv v0.15.2 // indirect 66 | github.com/pierrec/lz4/v4 v4.1.21 // indirect 67 | github.com/prometheus-community/pro-bing v0.4.0 // indirect 68 | github.com/rivo/uniseg v0.4.4 // indirect 69 | github.com/safchain/ethtool v0.3.0 // indirect 70 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect 71 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 72 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 73 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect 74 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect 75 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect 76 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect 77 | github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 // indirect 78 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect 79 | github.com/vishvananda/netns v0.0.4 // indirect 80 | github.com/x448/float16 v0.8.4 // indirect 81 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 82 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 83 | golang.org/x/crypto v0.35.0 // indirect 84 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect 85 | golang.org/x/mod v0.23.0 // indirect 86 | golang.org/x/net v0.36.0 // indirect 87 | golang.org/x/sync v0.11.0 // indirect 88 | golang.org/x/sys v0.30.0 // indirect 89 | golang.org/x/term v0.29.0 // indirect 90 | golang.org/x/text v0.22.0 // indirect 91 | golang.org/x/time v0.10.0 // indirect 92 | golang.org/x/tools v0.30.0 // indirect 93 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 94 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 95 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect 96 | ) 97 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "os" 9 | "sync" 10 | "time" 11 | 12 | "github.com/bscott/ts-chat/internal/chat" 13 | "tailscale.com/tsnet" 14 | ) 15 | 16 | // Server represents the chat server 17 | type Server struct { 18 | config Config 19 | listener net.Listener 20 | tsServer *tsnet.Server 21 | chatRoom *chat.Room 22 | ctx context.Context 23 | cancel context.CancelFunc 24 | wg sync.WaitGroup 25 | connections map[string]net.Conn 26 | mu sync.Mutex 27 | } 28 | 29 | // NewServer creates a new chat server 30 | func NewServer(cfg Config) (*Server, error) { 31 | ctx, cancel := context.WithCancel(context.Background()) 32 | 33 | // Create a new chat room 34 | room := chat.NewRoom(cfg.RoomName, cfg.MaxUsers, cfg.EnableHistory, cfg.HistorySize) 35 | 36 | return &Server{ 37 | config: cfg, 38 | ctx: ctx, 39 | cancel: cancel, 40 | chatRoom: room, 41 | connections: make(map[string]net.Conn), 42 | }, nil 43 | } 44 | 45 | // Start starts the chat server 46 | func (s *Server) Start() error { 47 | var listener net.Listener 48 | var err error 49 | 50 | if s.config.EnableTailscale { 51 | // Start the tsnet Tailscale server 52 | s.tsServer = &tsnet.Server{ 53 | Hostname: s.config.HostName, 54 | AuthKey: os.Getenv("TS_AUTHKEY"), 55 | } 56 | 57 | // Bring up the Tailscale node before listening 58 | // This ensures proper authentication with the authkey 59 | log.Printf("Connecting to Tailscale network...") 60 | if _, err := s.tsServer.Up(s.ctx); err != nil { 61 | return fmt.Errorf("failed to start Tailscale node: %w", err) 62 | } 63 | 64 | // Get Tailscale status to show DNS name 65 | ln, err := s.tsServer.LocalClient() 66 | if err != nil { 67 | log.Printf("Warning: unable to get Tailscale local client: %v", err) 68 | } else { 69 | status, err := ln.Status(s.ctx) 70 | if err != nil { 71 | log.Printf("Warning: unable to get Tailscale status: %v", err) 72 | } else if status != nil && status.Self != nil && status.Self.DNSName != "" { 73 | log.Printf("Tailscale node running as: %s", status.Self.DNSName) 74 | } else { 75 | log.Printf("Tailscale node running but DNS name not available yet") 76 | } 77 | } 78 | 79 | // Listen on the specified port 80 | listener, err = s.tsServer.Listen("tcp", fmt.Sprintf(":%d", s.config.Port)) 81 | if err != nil { 82 | return fmt.Errorf("failed to start Tailscale server on port %d: %w", s.config.Port, err) 83 | } 84 | } else { 85 | // Start a regular TCP server 86 | listener, err = net.Listen("tcp", fmt.Sprintf(":%d", s.config.Port)) 87 | if err != nil { 88 | return fmt.Errorf("failed to listen on port %d: %w", s.config.Port, err) 89 | } 90 | } 91 | 92 | s.listener = listener 93 | 94 | log.Printf("Server started on port %d (room: %s, max users: %d)", s.config.Port, s.config.RoomName, s.config.MaxUsers) 95 | 96 | // Accept connections 97 | s.wg.Add(1) 98 | go s.acceptConnections() 99 | 100 | return nil 101 | } 102 | 103 | // acceptConnections accepts incoming connections 104 | func (s *Server) acceptConnections() { 105 | defer s.wg.Done() 106 | 107 | for { 108 | select { 109 | case <-s.ctx.Done(): 110 | return 111 | default: 112 | conn, err := s.listener.Accept() 113 | if err != nil { 114 | // Check if server is shutting down 115 | select { 116 | case <-s.ctx.Done(): 117 | return 118 | default: 119 | log.Printf("Error accepting connection: %v", err) 120 | continue 121 | } 122 | } 123 | 124 | // Handle the connection in a new goroutine 125 | s.wg.Add(1) 126 | go s.handleConnection(conn) 127 | } 128 | } 129 | } 130 | 131 | // handleConnection handles a client connection 132 | func (s *Server) handleConnection(conn net.Conn) { 133 | defer s.wg.Done() 134 | defer conn.Close() 135 | 136 | remoteAddr := conn.RemoteAddr().String() 137 | log.Printf("New connection from %s", remoteAddr) 138 | 139 | // Register connection 140 | s.mu.Lock() 141 | s.connections[remoteAddr] = conn 142 | s.mu.Unlock() 143 | 144 | // Deregister connection when done 145 | defer func() { 146 | s.mu.Lock() 147 | delete(s.connections, remoteAddr) 148 | s.mu.Unlock() 149 | log.Printf("Connection from %s closed", remoteAddr) 150 | }() 151 | 152 | // Create a new client 153 | client, err := chat.NewClient(conn, s.chatRoom) 154 | if err != nil { 155 | log.Printf("Error creating client: %v", err) 156 | return 157 | } 158 | 159 | // Handle the client 160 | client.Handle(s.ctx) 161 | } 162 | 163 | // Stop stops the chat server 164 | func (s *Server) Stop() error { 165 | log.Print("Stopping chat server...") 166 | 167 | // Cancel the context to signal shutdown to all handlers 168 | s.cancel() 169 | 170 | // Close the listener first to stop accepting new connections 171 | if s.listener != nil { 172 | if err := s.listener.Close(); err != nil { 173 | log.Printf("Error closing listener: %v", err) 174 | } 175 | } 176 | 177 | // Close all active connections to unblock client handlers 178 | s.mu.Lock() 179 | for _, conn := range s.connections { 180 | conn.Close() 181 | } 182 | s.mu.Unlock() 183 | 184 | // Stop the chat room (this will now complete quickly since clients are disconnected) 185 | if s.chatRoom != nil { 186 | if err := s.chatRoom.Stop(); err != nil { 187 | log.Printf("Error stopping chat room: %v", err) 188 | } 189 | } 190 | 191 | // Close the tsnet server if in Tailscale mode 192 | if s.config.EnableTailscale && s.tsServer != nil { 193 | if err := s.tsServer.Close(); err != nil { 194 | log.Printf("Error closing Tailscale node: %v", err) 195 | } 196 | } 197 | 198 | // Wait for all goroutines to finish with a timeout 199 | done := make(chan struct{}) 200 | go func() { 201 | s.wg.Wait() 202 | close(done) 203 | }() 204 | 205 | select { 206 | case <-done: 207 | log.Print("Chat server stopped") 208 | case <-time.After(5 * time.Second): 209 | log.Print("Chat server stopped (timeout waiting for goroutines)") 210 | } 211 | 212 | return nil 213 | } -------------------------------------------------------------------------------- /internal/chat/room.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // Message represents a chat message 11 | type Message struct { 12 | From string 13 | Content string 14 | Timestamp time.Time 15 | IsSystem bool 16 | IsAction bool 17 | } 18 | 19 | // Room represents a chat room 20 | type Room struct { 21 | Name string 22 | MaxUsers int 23 | clients map[string]*Client 24 | broadcast chan Message 25 | join chan *Client 26 | leave chan *Client 27 | mu sync.RWMutex 28 | ctx context.Context 29 | cancel context.CancelFunc 30 | done chan struct{} 31 | enableHistory bool 32 | historySize int 33 | history []Message 34 | historyMu sync.RWMutex 35 | } 36 | 37 | // NewRoom creates a new chat room 38 | func NewRoom(name string, maxUsers int, enableHistory bool, historySize int) *Room { 39 | ctx, cancel := context.WithCancel(context.Background()) 40 | room := &Room{ 41 | Name: name, 42 | MaxUsers: maxUsers, 43 | clients: make(map[string]*Client), 44 | broadcast: make(chan Message), 45 | join: make(chan *Client), 46 | leave: make(chan *Client), 47 | ctx: ctx, 48 | cancel: cancel, 49 | done: make(chan struct{}), 50 | enableHistory: enableHistory, 51 | historySize: historySize, 52 | history: make([]Message, 0, historySize), 53 | } 54 | 55 | go room.run() 56 | return room 57 | } 58 | 59 | // run handles room events 60 | func (r *Room) run() { 61 | defer close(r.done) 62 | for { 63 | select { 64 | case <-r.ctx.Done(): 65 | return 66 | case client := <-r.join: 67 | r.addClient(client) 68 | case client := <-r.leave: 69 | r.removeClient(client) 70 | case msg := <-r.broadcast: 71 | r.broadcastMessage(msg) 72 | } 73 | } 74 | } 75 | 76 | // addClient adds a client to the room 77 | func (r *Room) addClient(c *Client) { 78 | r.mu.Lock() 79 | 80 | // Count actual clients (non-nil entries, excluding reservations) 81 | activeClients := 0 82 | for _, client := range r.clients { 83 | if client != nil { 84 | activeClients++ 85 | } 86 | } 87 | 88 | // Check if room is full 89 | if activeClients >= r.MaxUsers { 90 | // Remove the reservation since we can't add them 91 | delete(r.clients, c.Nickname) 92 | r.mu.Unlock() 93 | // Send message but don't close connection here 94 | // Connection handling should be done by the caller 95 | c.sendSystemMessage("Sorry, the room is full. Try again later.") 96 | // Signal that the client wasn't added by setting a flag 97 | c.fullRoomRejection = true 98 | return 99 | } 100 | 101 | // Add client to the room (replaces nil reservation with actual client) 102 | r.clients[c.Nickname] = c 103 | r.mu.Unlock() 104 | 105 | // Notify everyone that a new user has joined (outside of lock to avoid deadlock) 106 | systemMsg := Message{ 107 | From: "System", 108 | Content: fmt.Sprintf("%s has joined the room", c.Nickname), 109 | Timestamp: time.Now(), 110 | IsSystem: true, 111 | } 112 | r.broadcastMessage(systemMsg) 113 | } 114 | 115 | // removeClient removes a client from the room 116 | func (r *Room) removeClient(c *Client) { 117 | r.mu.Lock() 118 | _, exists := r.clients[c.Nickname] 119 | if exists { 120 | delete(r.clients, c.Nickname) 121 | } 122 | r.mu.Unlock() 123 | 124 | if exists { 125 | // Notify everyone that a user has left (outside of lock to avoid deadlock) 126 | systemMsg := Message{ 127 | From: "System", 128 | Content: fmt.Sprintf("%s has left the room", c.Nickname), 129 | Timestamp: time.Now(), 130 | IsSystem: true, 131 | } 132 | r.broadcastMessage(systemMsg) 133 | } 134 | } 135 | 136 | // broadcastMessage sends a message to all clients 137 | func (r *Room) broadcastMessage(msg Message) { 138 | // Store in history if enabled (for non-system messages or join/leave messages) 139 | if r.enableHistory { 140 | r.addToHistory(msg) 141 | } 142 | 143 | r.mu.RLock() 144 | defer r.mu.RUnlock() 145 | 146 | for _, client := range r.clients { 147 | if client != nil { 148 | go client.sendMessage(msg) // Use goroutine to avoid blocking 149 | } 150 | } 151 | } 152 | 153 | // addToHistory adds a message to the history buffer 154 | func (r *Room) addToHistory(msg Message) { 155 | r.historyMu.Lock() 156 | defer r.historyMu.Unlock() 157 | 158 | r.history = append(r.history, msg) 159 | 160 | // Trim history if it exceeds the max size 161 | if len(r.history) > r.historySize { 162 | r.history = r.history[len(r.history)-r.historySize:] 163 | } 164 | } 165 | 166 | // GetHistory returns the message history 167 | func (r *Room) GetHistory() []Message { 168 | r.historyMu.RLock() 169 | defer r.historyMu.RUnlock() 170 | 171 | // Return a copy to avoid race conditions 172 | history := make([]Message, len(r.history)) 173 | copy(history, r.history) 174 | return history 175 | } 176 | 177 | // Join adds a client to the room 178 | func (r *Room) Join(client *Client) { 179 | select { 180 | case r.join <- client: 181 | case <-r.ctx.Done(): 182 | // Room is shutting down, don't block 183 | } 184 | } 185 | 186 | // Leave removes a client from the room 187 | func (r *Room) Leave(client *Client) { 188 | select { 189 | case r.leave <- client: 190 | case <-r.ctx.Done(): 191 | // Room is shutting down, don't block 192 | } 193 | } 194 | 195 | // Broadcast sends a message to all clients 196 | func (r *Room) Broadcast(msg Message) { 197 | select { 198 | case r.broadcast <- msg: 199 | case <-r.ctx.Done(): 200 | // Room is shutting down, don't block 201 | } 202 | } 203 | 204 | // GetUserList returns a list of all users in the room 205 | func (r *Room) GetUserList() []string { 206 | r.mu.RLock() 207 | defer r.mu.RUnlock() 208 | 209 | users := make([]string, 0, len(r.clients)) 210 | for nickname := range r.clients { 211 | users = append(users, nickname) 212 | } 213 | 214 | return users 215 | } 216 | 217 | // IsNicknameAvailable checks if a nickname is available 218 | func (r *Room) IsNicknameAvailable(nickname string) bool { 219 | r.mu.RLock() 220 | defer r.mu.RUnlock() 221 | 222 | _, exists := r.clients[nickname] 223 | return !exists 224 | } 225 | 226 | // ReserveNickname atomically checks and reserves a nickname, returning true if successful 227 | func (r *Room) ReserveNickname(nickname string) bool { 228 | r.mu.Lock() 229 | defer r.mu.Unlock() 230 | 231 | if _, exists := r.clients[nickname]; exists { 232 | return false 233 | } 234 | 235 | // Reserve with a nil client temporarily - will be replaced by actual client on Join 236 | r.clients[nickname] = nil 237 | return true 238 | } 239 | 240 | // ReleaseNickname releases a reserved nickname if the client is nil (reservation only) 241 | func (r *Room) ReleaseNickname(nickname string) { 242 | r.mu.Lock() 243 | defer r.mu.Unlock() 244 | 245 | if client, exists := r.clients[nickname]; exists && client == nil { 246 | delete(r.clients, nickname) 247 | } 248 | } 249 | 250 | // Stop gracefully shuts down the room 251 | func (r *Room) Stop() error { 252 | // Cancel the context to signal the run loop to exit 253 | r.cancel() 254 | 255 | // Wait for the run goroutine to finish 256 | <-r.done 257 | 258 | // Close all channels 259 | close(r.broadcast) 260 | close(r.join) 261 | close(r.leave) 262 | 263 | return nil 264 | } -------------------------------------------------------------------------------- /internal/chat/client.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/bscott/ts-chat/internal/ui" 15 | ) 16 | 17 | // Constants for rate limiting and validation 18 | const ( 19 | MaxMessageLength = 1000 // Maximum message length in characters 20 | MessageRateLimit = 5 // Maximum messages per second 21 | RateLimitWindow = 5 * time.Second // Time window for rate limiting 22 | MaxNicknameLen = 20 // Maximum nickname length 23 | MinNicknameLen = 2 // Minimum nickname length 24 | ) 25 | 26 | // ANSI escape codes for terminal control 27 | const ( 28 | cursorUp = "\033[1A" // Move cursor up one line 29 | clearLine = "\033[2K" // Clear entire line 30 | cursorToStart = "\033[0G" // Move cursor to start of line 31 | inputPrompt = "> " // Input prompt indicator 32 | ) 33 | 34 | // validateNickname checks if a nickname is valid 35 | func validateNickname(nickname string) error { 36 | if nickname == "" { 37 | return fmt.Errorf("Nickname cannot be empty. Please try again.") 38 | } 39 | 40 | if len(nickname) < MinNicknameLen { 41 | return fmt.Errorf("Nickname must be at least %d characters.", MinNicknameLen) 42 | } 43 | 44 | if len(nickname) > MaxNicknameLen { 45 | return fmt.Errorf("Nickname must be at most %d characters.", MaxNicknameLen) 46 | } 47 | 48 | if strings.ToLower(nickname) == "system" { 49 | return fmt.Errorf("Nickname 'System' is reserved. Please choose another nickname.") 50 | } 51 | 52 | // Check for valid characters (alphanumeric, underscore, hyphen) 53 | for _, r := range nickname { 54 | if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-') { 55 | return fmt.Errorf("Nickname can only contain letters, numbers, underscores, and hyphens.") 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | 62 | // Client represents a chat client 63 | type Client struct { 64 | Nickname string 65 | conn net.Conn 66 | reader *bufio.Reader 67 | writer *bufio.Writer 68 | room *Room 69 | mu sync.Mutex // Mutex to protect concurrent writes 70 | fullRoomRejection bool // Flag indicating client was rejected due to room being full 71 | messageTimestamps []time.Time // Timestamps of recent messages for rate limiting 72 | rateLimitMu sync.Mutex // Mutex for rate limiting data 73 | } 74 | 75 | // NewClient creates a new chat client 76 | func NewClient(conn net.Conn, room *Room) (*Client, error) { 77 | client := &Client{ 78 | conn: conn, 79 | reader: bufio.NewReader(conn), 80 | writer: bufio.NewWriter(conn), 81 | room: room, 82 | fullRoomRejection: false, 83 | messageTimestamps: make([]time.Time, 0, MessageRateLimit*2), 84 | } 85 | 86 | // Ask for nickname 87 | if err := client.requestNickname(); err != nil { 88 | // Ensure connection is closed on error 89 | conn.Close() 90 | return nil, fmt.Errorf("nickname request failed: %w", err) 91 | } 92 | 93 | // Join the room 94 | room.Join(client) 95 | 96 | // Check if client was rejected due to room being full 97 | if client.fullRoomRejection { 98 | // Close the connection since the room is full 99 | conn.Close() 100 | return nil, fmt.Errorf("room is full") 101 | } 102 | 103 | // Send welcome message 104 | if err := client.sendWelcomeMessage(); err != nil { 105 | // Leave the room since we encountered an error (this also removes the client/reservation) 106 | room.Leave(client) 107 | // Close the connection 108 | conn.Close() 109 | return nil, fmt.Errorf("welcome message failed: %w", err) 110 | } 111 | 112 | // Send message history to the new client 113 | client.sendHistory() 114 | 115 | return client, nil 116 | } 117 | 118 | // requestNickname asks the user for a nickname 119 | func (c *Client) requestNickname() error { 120 | // Send welcome message 121 | if err := c.write(ui.FormatTitle("Welcome to Chat Tails") + "\r\n\r\n"); err != nil { 122 | return fmt.Errorf("failed to write welcome message: %w", err) 123 | } 124 | 125 | // Ask for nickname 126 | for { 127 | if err := c.write("Please enter your nickname: "); err != nil { 128 | return fmt.Errorf("failed to write nickname prompt: %w", err) 129 | } 130 | 131 | // Read nickname 132 | nickname, err := c.reader.ReadString('\n') 133 | if err != nil { 134 | return fmt.Errorf("failed to read nickname: %w", err) 135 | } 136 | 137 | // Trim whitespace 138 | nickname = strings.TrimSpace(nickname) 139 | 140 | // Validate nickname 141 | if err := validateNickname(nickname); err != nil { 142 | if writeErr := c.write(err.Error() + "\r\n"); writeErr != nil { 143 | return fmt.Errorf("failed to write error message: %w", writeErr) 144 | } 145 | continue 146 | } 147 | 148 | // Atomically reserve the nickname to prevent race conditions 149 | if !c.room.ReserveNickname(nickname) { 150 | errMsg := fmt.Sprintf("Nickname '%s' is already taken. Please choose another nickname.\r\n", nickname) 151 | if err := c.write(errMsg); err != nil { 152 | return fmt.Errorf("failed to write error message: %w", err) 153 | } 154 | continue 155 | } 156 | 157 | // Set nickname - reservation successful 158 | c.Nickname = nickname 159 | break 160 | } 161 | 162 | return nil 163 | } 164 | 165 | // sendWelcomeMessage sends a welcome message to the client 166 | func (c *Client) sendWelcomeMessage() error { 167 | banner := ` 168 | ╔════════════════════════════════════════════════════════════╗ 169 | ║ ║ 170 | ║ _____ _ _ _____ _ _ ║ 171 | ║ / ____| | | | |_ _| (_) | ║ 172 | ║ | | | |__ __ _| |_ | | __ _ _| |___ ║ 173 | ║ | | | '_ \ / _' | __| | |/ _' | | / __| ║ 174 | ║ | |____| | | | (_| | |_ | | (_| | | \__ \ ║ 175 | ║ \_____|_| |_|\__,_|\__| |_|\__,_|_|_|___/ ║ 176 | ║ ║ 177 | ╚════════════════════════════════════════════════════════════╝ 178 | ` 179 | coloredBanner := ui.SystemStyle.Render(banner) 180 | welcomeMsg := ui.FormatWelcomeMessage(c.room.Name, c.Nickname) 181 | 182 | if err := c.write(coloredBanner + "\r\n"); err != nil { 183 | return fmt.Errorf("failed to write banner: %w", err) 184 | } 185 | 186 | if err := c.write(welcomeMsg + "\r\n\r\n"); err != nil { 187 | return fmt.Errorf("failed to write welcome message: %w", err) 188 | } 189 | 190 | return nil 191 | } 192 | 193 | // sendHistory sends the message history to the client 194 | func (c *Client) sendHistory() { 195 | history := c.room.GetHistory() 196 | if len(history) == 0 { 197 | return 198 | } 199 | 200 | c.write(ui.FormatSystemMessage("--- Recent messages ---") + "\r\n") 201 | 202 | for _, msg := range history { 203 | c.sendMessage(msg) 204 | } 205 | 206 | c.write(ui.FormatSystemMessage("--- End of history ---") + "\r\n\r\n") 207 | } 208 | 209 | // Handle handles client interactions 210 | func (c *Client) Handle(ctx context.Context) { 211 | // Cleanup when done 212 | defer func() { 213 | c.room.Leave(c) 214 | c.close() 215 | }() 216 | 217 | // Start a goroutine to close connection when context is cancelled 218 | go func() { 219 | <-ctx.Done() 220 | c.close() 221 | }() 222 | 223 | // Show initial prompt 224 | c.showPrompt() 225 | 226 | // Handle client messages 227 | for { 228 | // Set read deadline to allow periodic context checking 229 | if conn, ok := c.conn.(interface{ SetReadDeadline(time.Time) error }); ok { 230 | conn.SetReadDeadline(time.Now().Add(30 * time.Second)) 231 | } 232 | 233 | line, err := c.reader.ReadString('\n') 234 | if err != nil { 235 | // Check if context was cancelled 236 | if ctx.Err() != nil { 237 | return 238 | } 239 | 240 | // Check for timeout - just continue to check context 241 | if netErr, ok := err.(net.Error); ok && netErr.Timeout() { 242 | continue 243 | } 244 | 245 | if err == io.EOF { 246 | // Client disconnected normally 247 | return 248 | } 249 | 250 | log.Printf("Error reading from client %s: %v", c.Nickname, err) 251 | return 252 | } 253 | 254 | // Process the message 255 | message := strings.TrimSpace(line) 256 | 257 | // Clear the input line (move up and clear the echoed input) 258 | c.clearInputLine() 259 | 260 | // Skip empty messages 261 | if message == "" { 262 | c.showPrompt() 263 | continue 264 | } 265 | 266 | // Validate message length 267 | if err := c.validateMessageLength(message); err != nil { 268 | c.sendSystemMessage(fmt.Sprintf("Error: %v", err)) 269 | c.showPrompt() 270 | continue 271 | } 272 | 273 | // Check rate limiting (except for /quit command) 274 | if !strings.HasPrefix(message, "/quit") { 275 | if err := c.checkRateLimit(); err != nil { 276 | c.sendSystemMessage(fmt.Sprintf("Error: %v", err)) 277 | c.showPrompt() 278 | continue 279 | } 280 | } 281 | 282 | // Handle command or regular message 283 | if strings.HasPrefix(message, "/") { 284 | if err := c.handleCommand(message); err != nil { 285 | // Error already sent to client in handleCommand 286 | } 287 | } else { 288 | // Send message to room 289 | c.room.Broadcast(Message{ 290 | From: c.Nickname, 291 | Content: message, 292 | Timestamp: time.Now(), 293 | }) 294 | } 295 | 296 | // Show prompt for next input 297 | c.showPrompt() 298 | } 299 | } 300 | 301 | // clearInputLine clears the echoed input line 302 | func (c *Client) clearInputLine() { 303 | c.write(cursorUp + clearLine + cursorToStart) 304 | } 305 | 306 | // showPrompt displays the input prompt 307 | func (c *Client) showPrompt() { 308 | c.write(inputPrompt) 309 | } 310 | 311 | // close closes the client connection safely 312 | func (c *Client) close() { 313 | c.mu.Lock() 314 | defer c.mu.Unlock() 315 | 316 | if c.conn != nil { 317 | c.conn.Close() 318 | c.conn = nil 319 | } 320 | } 321 | 322 | // validateMessageLength checks if a message is within the allowed length 323 | func (c *Client) validateMessageLength(message string) error { 324 | if len(message) > MaxMessageLength { 325 | return fmt.Errorf("message too long (max %d characters)", MaxMessageLength) 326 | } 327 | return nil 328 | } 329 | 330 | // checkRateLimit checks if the client is sending messages too quickly 331 | func (c *Client) checkRateLimit() error { 332 | now := time.Now() 333 | c.rateLimitMu.Lock() 334 | defer c.rateLimitMu.Unlock() 335 | 336 | // Add current timestamp 337 | c.messageTimestamps = append(c.messageTimestamps, now) 338 | 339 | // Remove timestamps outside the window 340 | cutoff := now.Add(-RateLimitWindow) 341 | newTimestamps := make([]time.Time, 0, len(c.messageTimestamps)) 342 | 343 | for _, ts := range c.messageTimestamps { 344 | if ts.After(cutoff) { 345 | newTimestamps = append(newTimestamps, ts) 346 | } 347 | } 348 | 349 | c.messageTimestamps = newTimestamps 350 | 351 | // Check if we have too many messages in the window 352 | if len(c.messageTimestamps) > MessageRateLimit { 353 | waitTime := c.messageTimestamps[0].Add(RateLimitWindow).Sub(now) 354 | return fmt.Errorf("rate limit exceeded (max %d messages per %s). Try again in %.1f seconds", 355 | MessageRateLimit, RateLimitWindow, waitTime.Seconds()) 356 | } 357 | 358 | return nil 359 | } 360 | 361 | // handleCommand handles a command from the client 362 | func (c *Client) handleCommand(cmd string) error { 363 | parts := strings.SplitN(cmd, " ", 2) 364 | command := strings.ToLower(parts[0]) 365 | 366 | switch command { 367 | case "/who": 368 | return c.showUserList() 369 | 370 | case "/me": 371 | if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" { 372 | c.sendSystemMessage("Usage: /me ") 373 | return fmt.Errorf("invalid /me command usage") 374 | } 375 | action := parts[1] 376 | c.room.Broadcast(Message{ 377 | From: c.Nickname, 378 | Content: action, 379 | Timestamp: time.Now(), 380 | IsAction: true, 381 | }) 382 | 383 | case "/help": 384 | return c.showHelp() 385 | 386 | case "/quit": 387 | c.sendSystemMessage("Goodbye!") 388 | c.close() 389 | return nil 390 | 391 | default: 392 | c.sendSystemMessage(fmt.Sprintf("Unknown command: %s", command)) 393 | return fmt.Errorf("unknown command: %s", command) 394 | } 395 | 396 | return nil 397 | } 398 | 399 | // showUserList shows the list of users in the room 400 | func (c *Client) showUserList() error { 401 | users := c.room.GetUserList() 402 | msg := ui.FormatUserList(c.room.Name, users, c.room.MaxUsers) 403 | return c.write(msg + "\r\n") 404 | } 405 | 406 | // showHelp shows the help message 407 | func (c *Client) showHelp() error { 408 | helpMsg := ui.FormatHelp() 409 | return c.write(helpMsg + "\r\n") 410 | } 411 | 412 | // sendSystemMessage sends a system message to the client 413 | func (c *Client) sendSystemMessage(message string) { 414 | msg := Message{ 415 | From: "System", 416 | Content: message, 417 | Timestamp: time.Now(), 418 | IsSystem: true, 419 | } 420 | 421 | c.sendMessage(msg) 422 | } 423 | 424 | // sendMessage sends a message to the client 425 | func (c *Client) sendMessage(msg Message) { 426 | var formatted string 427 | timeStr := msg.Timestamp.Format("15:04:05") 428 | 429 | if msg.IsSystem { 430 | formatted = ui.FormatSystemMessage(msg.Content) + "\r\n" 431 | } else if msg.IsAction { 432 | formatted = ui.FormatActionMessage(msg.From, msg.Content) + "\r\n" 433 | } else { 434 | formatted = ui.FormatUserMessage(msg.From, msg.Content, timeStr) + "\r\n" 435 | } 436 | 437 | c.mu.Lock() 438 | defer c.mu.Unlock() 439 | 440 | // Check if connection is still valid 441 | if c.conn == nil { 442 | return 443 | } 444 | 445 | if _, err := c.writer.WriteString(formatted); err != nil { 446 | log.Printf("Error writing message to %s: %v", c.Nickname, err) 447 | return 448 | } 449 | 450 | if err := c.writer.Flush(); err != nil { 451 | log.Printf("Error flushing message to %s: %v", c.Nickname, err) 452 | return 453 | } 454 | } 455 | 456 | // write writes a message to the client 457 | func (c *Client) write(message string) error { 458 | c.mu.Lock() 459 | defer c.mu.Unlock() 460 | 461 | // Check if connection is still valid 462 | if c.conn == nil { 463 | return fmt.Errorf("connection closed") 464 | } 465 | 466 | if _, err := c.writer.WriteString(message); err != nil { 467 | return fmt.Errorf("error writing message: %w", err) 468 | } 469 | 470 | if err := c.writer.Flush(); err != nil { 471 | return fmt.Errorf("error flushing message: %w", err) 472 | } 473 | 474 | return nil 475 | } -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= 4 | filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= 5 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= 6 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 7 | github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 8 | github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 9 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 10 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 11 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 12 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 13 | github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk= 14 | github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= 15 | github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= 16 | github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= 17 | github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= 18 | github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= 19 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= 20 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= 21 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ= 22 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI= 23 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o= 24 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k= 25 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= 26 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= 27 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA= 28 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY= 29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM= 30 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk= 31 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= 32 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= 33 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= 34 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= 35 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= 36 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= 37 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc= 38 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w= 39 | github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= 40 | github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 41 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 42 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 43 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 44 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 45 | github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= 46 | github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= 47 | github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= 48 | github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 49 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= 50 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 51 | github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= 52 | github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 53 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 54 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 55 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 56 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= 57 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= 58 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= 59 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= 60 | github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= 61 | github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= 62 | github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= 63 | github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= 64 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 65 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 66 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 67 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 68 | github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= 69 | github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= 70 | github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= 71 | github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= 72 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= 73 | github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 74 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 75 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 76 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= 77 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= 78 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 79 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 80 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 81 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 82 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 83 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 84 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 85 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 86 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= 87 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= 88 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 89 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 90 | github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M= 91 | github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 92 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 93 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 94 | github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= 95 | github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 96 | github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= 97 | github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= 98 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= 99 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= 100 | github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= 101 | github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= 102 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 103 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 104 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 105 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 106 | github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= 107 | github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= 108 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 109 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 110 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= 111 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= 112 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 113 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 114 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 115 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 116 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 117 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 118 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 119 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 120 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 121 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 122 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 123 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 124 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 125 | github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 126 | github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 127 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= 128 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= 129 | github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= 130 | github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= 131 | github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 132 | github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 133 | github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= 134 | github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= 135 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 136 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 137 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 138 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 139 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 140 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 141 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 142 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 143 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 144 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 145 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 146 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 147 | github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= 148 | github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= 149 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 150 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 151 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 152 | github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= 153 | github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= 154 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 155 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 156 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 157 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 158 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 159 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 160 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 161 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 162 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 163 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 164 | github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= 165 | github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= 166 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 167 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 168 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 169 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 170 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 171 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= 172 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= 173 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 174 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 175 | github.com/tailscale/golang-x-crypto v0.0.0-20250218230618-9a281fd8faca h1:ecjHwH73Yvqf/oIdQ2vxAX+zc6caQsYdPzsxNW1J3G8= 176 | github.com/tailscale/golang-x-crypto v0.0.0-20250218230618-9a281fd8faca/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= 177 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= 178 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= 179 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= 180 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 181 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= 182 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 183 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= 184 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= 185 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= 186 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= 187 | github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= 188 | github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= 189 | github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw= 190 | github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= 191 | github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= 192 | github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= 193 | github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= 194 | github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= 195 | github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= 196 | github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= 197 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= 198 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= 199 | github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= 200 | github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= 201 | github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 202 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 203 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 204 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= 205 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 206 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 207 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 208 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 209 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 210 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= 211 | golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= 212 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= 213 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 214 | golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= 215 | golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= 216 | golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= 217 | golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 218 | golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= 219 | golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= 220 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 221 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 222 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 223 | golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 224 | golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 225 | golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 226 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 227 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 228 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 229 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 230 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 231 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 232 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 233 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 234 | golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= 235 | golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 236 | golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= 237 | golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= 238 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 239 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 240 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 241 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 242 | google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= 243 | google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 244 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 245 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 246 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 247 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 248 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 249 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 250 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k= 251 | gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= 252 | honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= 253 | honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= 254 | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 255 | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 256 | software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= 257 | software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 258 | tailscale.com v1.82.5 h1:p5owmyPoPM1tFVHR3LjquFuLfpZLzafvhe5kjVavHtE= 259 | tailscale.com v1.82.5/go.mod h1:iU6kohVzG+bP0/5XjqBAnW8/6nSG/Du++bO+x7VJZD0= 260 | --------------------------------------------------------------------------------