diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml new file mode 100644 index 0000000..7e3fb9f --- /dev/null +++ b/.github/codeql-config.yml @@ -0,0 +1,38 @@ +name: "Ticket Daemon CodeQL Config" + +# Query settings +disable-default-queries: false + +# Queries to run +queries: + - uses: security-extended + - uses: security-and-quality + +# Exclude test files, examples, and vendored code +paths-ignore: + # Test files + - '**/*_test.go' + - 'test/**' + - 'internal/testutils/**' + + # Examples (not production code) + - 'examples/**' + + # Vendored dependencies + - 'vendor/**' + + # Test data + - 'testdata/**' + + # Build artifacts + - '*.exe' + - '*.dll' + - '*.so' + + # IDE files + - '.idea/**' + - '.vscode/**' + +# Advanced: Query packs +packs: + - codeql/go-queries \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..fb5a5fe --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## πŸ“ Description + +## πŸ§ͺ Testing Strategy + +- [ ] Unit tests passed locally +- [ ] Manual test on **Local** environment +- [ ] Manual test on **Remote** environment +- [ ] Verified build with `task build` + +## βœ… Checklist + +- [ ] Code follows project style (ran `gofmt` / `golangci-lint`) +- [ ] Self-reviewed code +- [ ] No new meaningful warnings generated \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd2e2a4..35e1fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,18 @@ name: CI on: push: branches: [ main, master ] + paths-ignore: + - '**.md' + - 'docs/**' pull_request: branches: [ main, master ] + paths-ignore: + - '**.md' + - 'docs/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true env: GO_VERSION: '1.24.x' @@ -13,20 +23,74 @@ permissions: contents: read jobs: + pr-validation: + name: πŸ“‹ PR Validation + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Validate PR Title + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + deps + revert + scopes: | + github + api + web + auth + config + daemon + scale + server + general + requireScope: true + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject must start with lowercase letter. + test: name: πŸ§ͺ Test and Coverage runs-on: ubuntu-latest + timeout-minutes: 10 # πŸ›‘ Hard limit steps: - name: Checkout code uses: actions/checkout@v6 + - name: Checkout Poster library + uses: actions/checkout@v6 + with: + repository: adcondev/poster + path: poster + - name: Setup Go uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} cache: true + - name: Patch Go modules for CI + run: | + go mod edit -replace github.com/adcondev/poster=./poster + - name: Run tests with race detection run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... @@ -45,6 +109,7 @@ jobs: flags: unittests name: codecov-ubuntu fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} benchmark: name: ⚑ Performance Benchmarks @@ -61,6 +126,12 @@ jobs: with: fetch-depth: 0 + - name: Checkout Poster library + uses: actions/checkout@v6 + with: + repository: adcondev/poster + path: poster + - name: Setup Go uses: actions/setup-go@v6 with: @@ -70,14 +141,20 @@ jobs: - name: Run benchmarks (base) continue-on-error: true run: | - git clean -fdx + # Ignore our cloned repos so git clean doesn't delete them + git clean -fdx -e poster/ + git reset --hard git checkout ${{ github.event.pull_request.base.sha }} - go test -bench=. -benchmem -run=^$ ./... > /tmp/base-benchmark.txt 2>&1 + # Re-apply the patch because checkout restores the base go.mod + go mod edit -replace github.com/adcondev/poster=./poster + go test -bench=. -benchmem -run=^$ ./... > /tmp/base-benchmark.txt 2>&1 || true - name: Run benchmarks (current) run: | - git clean -fdx + git clean -fdx -e poster/ + git reset --hard git checkout ${{ github.event.pull_request.head.sha }} + go mod edit -replace github.com/adcondev/poster=./poster go test -bench=. -benchmem -run=^$ ./... > /tmp/current-benchmark.txt 2>&1 - name: Compare benchmarks @@ -92,7 +169,7 @@ jobs: echo '```' >> benchmark-comment.md grep "^Benchmark" /tmp/current-benchmark.txt | head -20 >> benchmark-comment.md echo '```' >> benchmark-comment.md - + if grep -q "^Benchmark" /tmp/base-benchmark.txt; then echo "" >> benchmark-comment.md echo "### πŸ“Š Base Branch Results" >> benchmark-comment.md @@ -152,15 +229,70 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + - name: Checkout Poster library + uses: actions/checkout@v6 + with: + repository: adcondev/poster + path: poster + - name: Setup Go uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} cache: true + - name: Patch Go modules for CI + run: | + go mod edit -replace github.com/adcondev/poster=./poster + - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: version: latest skip-cache: false - args: --config=./.golangci.yml --timeout=5m + args: --config=.golangci.yml --timeout=5m + + build: + name: πŸ—οΈ Build Check + runs-on: ubuntu-latest + needs: test # Only build if tests pass + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Checkout Poster library + uses: actions/checkout@v6 + with: + repository: adcondev/poster + path: poster + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Patch Go modules for CI + run: | + go mod edit -replace github.com/adcondev/poster=./poster + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build via Taskfile + env: + GOOS: windows + GOARCH: amd64 + SCALE_AUTH_TOKEN: ${{ secrets.SCALE_AUTH_TOKEN || 'build-token' }} + SCALE_DASHBOARD_HASH: ${{ secrets.SCALE_DASHBOARD_HASH || '' }} + BUILD_ENV: 'remote' + run: | + task build + + echo "## πŸ“¦ Build Artifact" >> $GITHUB_STEP_SUMMARY + echo "| File | Size |" >> $GITHUB_STEP_SUMMARY + echo "|------|------|" >> $GITHUB_STEP_SUMMARY + ls -lh bin/*.exe | awk '{print "| " $9 " | " $5 " |"}' >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9f5f27b..fae5e96 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,12 +8,14 @@ on: - 'go.mod' - 'go.sum' - '.github/workflows/codeql.yml' + - '.github/codeql-config.yml' # Trigger on config changes too pull_request: branches: [ main, master ] paths: - '**.go' - 'go.mod' - 'go.sum' + - '.github/codeql-config.yml' schedule: # Run every Monday at midnight UTC - cron: '0 0 * * 1' @@ -51,6 +53,8 @@ jobs: uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} + # ⬇️ CRITICAL: Links to your config file ⬇️ + config-file: ./.github/codeql-config.yml - name: Autobuild uses: github/codeql-action/autobuild@v4 @@ -69,4 +73,4 @@ jobs: echo "**Language:** Go" >> $GITHUB_STEP_SUMMARY echo "**Status:** ${{ job.status }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "πŸ“Š [View detailed results](https://github.com/${{ github.repository }}/security/code-scanning)" >> $GITHUB_STEP_SUMMARY + echo "πŸ“Š [View detailed results](https://github.com/${{ github.repository }}/security/code-scanning)" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 4d40abe..6ca6e8e 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -53,8 +53,10 @@ jobs: pr-comment: name: PR Comment runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.action == 'opened' - + if: >- + github.event_name == 'pull_request' && + github.event.action == 'opened' && + github.actor != github.repository_owner steps: - name: Comment on PR uses: actions/github-script@v8 diff --git a/.gitignore b/.gitignore index cd57f9f..c787906 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,34 @@ -bin -.task -tmp \ No newline at end of file +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# Security - never commit secrets +.env +.env.* +!.env.example + +# Editor/IDE +# .idea/ +# .vscode/ diff --git a/README.md b/README.md index a3b87ab..8399d5d 100644 --- a/README.md +++ b/README.md @@ -166,3 +166,89 @@ Los logs se almacenan en `%PROGRAMDATA%` con un sistema de **autorrotaciΓ³n** pa * **Ruta**: `C:\ProgramData\R2k_Bascula_Remote\R2k_Bascula_Remote.log` * **LΓ­mite**: 5 MB (al excederse, se conservan las ΓΊltimas 1000 lΓ­neas para trazabilidad). + +--- + +## πŸ” Seguridad + +Scale Daemon implementa un modelo de seguridad por capas, diseΓ±ado para entornos de retail donde se necesita +proteger la configuraciΓ³n del servicio sin impactar la lectura de peso en tiempo real. + +### Capas de ProtecciΓ³n + +| Capa | Protege | Mecanismo | +|---------------------|----------------------------------------|------------------------------------------------| +| **Dashboard Login** | Acceso al panel de control (`/`) | ContraseΓ±a + sesiΓ³n con cookie HttpOnly | +| **Config Token** | Cambios de configuraciΓ³n vΓ­a WebSocket | Token de autorizaciΓ³n en cada mensaje `config` | +| **Rate Limiter** | Abuso de configuraciΓ³n | MΓ‘ximo 3 cambios por minuto por conexiΓ³n | +| **Brute Force** | Ataques de fuerza bruta al login | Bloqueo de IP tras 5 intentos fallidos (5 min) | + +### Modelo de Acceso por Endpoint + +```text +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ PÚBLICO (sin autenticaciΓ³n) β”‚ +β”‚ β”œβ”€β”€ GET /login PΓ‘gina de login β”‚ +β”‚ β”œβ”€β”€ POST /auth/login Procesar login β”‚ +β”‚ β”œβ”€β”€ GET /ping VerificaciΓ³n de latencia β”‚ +β”‚ β”œβ”€β”€ GET /health DiagnΓ³stico del servicio β”‚ +β”‚ β”œβ”€β”€ WS /ws Streaming de peso + config (token) β”‚ +β”‚ β”œβ”€β”€ GET /css/* Archivos estΓ‘ticos β”‚ +β”‚ └── GET /js/* Archivos estΓ‘ticos β”‚ +β”‚ β”‚ +β”‚ PROTEGIDO (sesiΓ³n requerida) β”‚ +β”‚ └── GET / Dashboard (inyecta config token) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +> **Nota:** El endpoint `/ws` es pΓΊblico para permitir que aplicaciones POS reciban peso sin necesidad de +> autenticarse en el dashboard. Los cambios de configuraciΓ³n dentro del WebSocket estΓ‘n protegidos por el +> `authToken`, que sΓ³lo estΓ‘ disponible para sesiones autenticadas a travΓ©s del dashboard. + +### ConfiguraciΓ³n + +Los secretos se definen en un archivo `.env` en el directorio del build system (`poster-tuis/`): + +```env +# ⚠️ NO commitear a control de versiones +DASHBOARD_PASSWORD=MiContraseΓ±a2026 +CONFIG_AUTH_TOKEN=mi-token-secreto +``` + +| Variable | VacΓ­o = | DescripciΓ³n | +|----------------------|--------------------------------------------------|----------------------------------------------------| +| `DASHBOARD_PASSWORD` | Auth deshabilitado (acceso directo al dashboard) | ContraseΓ±a para el login del dashboard | +| `CONFIG_AUTH_TOKEN` | Config sin validaciΓ³n de token | Token requerido en mensajes `config` vΓ­a WebSocket | + +### Pipeline de InyecciΓ³n + +```text +.env (plaintext) + β†’ hashpw (bcrypt + base64) + β†’ ldflags -X PasswordHashB64=... + β†’ binario compilado (sin plaintext) +``` + +La contraseΓ±a **nunca** se almacena en texto plano en el binario. Se inyecta como un hash bcrypt codificado +en base64 mediante `ldflags` durante la compilaciΓ³n. El token de configuraciΓ³n se inyecta directamente +(no es un secreto criptogrΓ‘fico, es un valor de autorizaciΓ³n). + +### Sesiones + +- DuraciΓ³n: **15 minutos** (configurable en `auth.go`) +- Cookie: `sd_session`, `HttpOnly`, `SameSite=Strict` +- Almacenamiento: en memoria del proceso (se pierden al reiniciar el servicio) +- Limpieza automΓ‘tica: goroutine periΓ³dica cada 5 minutos + +### AuditorΓ­a + +Todos los eventos de seguridad se registran con el prefijo `[AUDIT]`: + +``` +[AUDIT] LOGIN_SUCCESS | IP=192.168.1.100:54321 +[AUDIT] LOGIN_FAILED | IP=192.168.1.100:54322 +[AUDIT] LOGIN_BLOCKED | IP=192.168.1.100:54323 | reason=lockout +[AUDIT] CONFIG_ACCEPTED | puerto=COM4 marca=Rhino modoPrueba=false +[AUDIT] CONFIG_REJECTED | reason=invalid_token | puerto=COM4 marca=Rhino +[AUDIT] CONFIG_RATE_LIMITED | client=0xc0001a2000 +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..cd98b43 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,40 @@ +version: '3' + +# Carga variables desde el archivo .env automΓ‘ticamente +dotenv: [ '.env' ] + +vars: + # Ruta del paquete de configuraciΓ³n donde estΓ‘n las variables globales + CONFIG_PKG: github.com/adcondev/scale-daemon/internal/config + BINARY_NAME: R2k_ScaleServicio_Local.exe + +tasks: + build: + desc: Compila el servicio inyectando credenciales desde .env (Modo Consola) + cmds: + - echo "πŸ”¨ Compilando {{.BINARY_NAME}}..." + # Se usa -ldflags para inyectar las variables en tiempo de compilaciΓ³n. + # Se eliminΓ³ -H=windowsgui para permitir ver logs en consola. + - > + go build -ldflags "-s -w + -X '{{.CONFIG_PKG}}.AuthToken={{.SCALE_AUTH_TOKEN}}' + -X '{{.CONFIG_PKG}}.PasswordHashB64={{.SCALE_DASHBOARD_HASH}}' + -X '{{.CONFIG_PKG}}.BuildEnvironment={{.BUILD_ENV}}' + -X '{{.CONFIG_PKG}}.ServiceName=R2k_ScaleServicio'" + -o bin/{{.BINARY_NAME}} ./cmd/BasculaServicio + - echo "βœ… CompilaciΓ³n exitosa en bin/{{.BINARY_NAME}}" + + run: + desc: Compila y ejecuta inmediatamente + deps: [ build ] + cmds: + - ./bin/{{.BINARY_NAME}} -console + + clean: + desc: Limpia los artefactos de compilaciΓ³n + cmds: + - cmd: rm -rf bin/ + platforms: [ linux, darwin ] + - cmd: powershell -Command "Remove-Item -Recurse -Force bin/" + platforms: [ windows ] + - echo "🧹 Limpieza completada" \ No newline at end of file diff --git a/api/v1/scale_websocket.schema.json b/api/v1/scale_websocket.schema.json index fa704b6..518b4b5 100644 --- a/api/v1/scale_websocket.schema.json +++ b/api/v1/scale_websocket.schema.json @@ -44,7 +44,8 @@ "tipo", "puerto", "marca", - "modoPrueba" + "modoPrueba", + "authToken" ], "properties": { "tipo": { @@ -53,8 +54,7 @@ "puerto": { "type": "string", "examples": [ - "COM3", - "/dev/ttyUSB0" + "COM3" ] }, "marca": { @@ -65,6 +65,30 @@ }, "modoPrueba": { "type": "boolean" + }, + "authToken": { + "type": "string", + "description": "Authentication token required to authorize config changes. Injected into dashboard HTML at render time." + } + } + }, + "ErrorResponse": { + "type": "object", + "required": [ + "tipo", + "error" + ], + "properties": { + "tipo": { + "const": "error" + }, + "error": { + "type": "string", + "enum": [ + "AUTH_INVALID_TOKEN", + "RATE_LIMITED" + ], + "description": "Error code for rejected operations" } } }, diff --git a/docs/old/v0.1.0/html/index_config.html b/docs/old/v0.1.0/html/index_config.html index 184b3ae..9253019 100644 --- a/docs/old/v0.1.0/html/index_config.html +++ b/docs/old/v0.1.0/html/index_config.html @@ -14,6 +14,7 @@

Ejemplo de bΓ‘scula

Marca:
Modo prueba: DirecciΓ³n: + Token:

diff --git a/go.mod b/go.mod index b7cb864..19e3405 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,10 @@ require ( github.com/coder/websocket v1.8.14 github.com/judwhite/go-svc v1.2.1 go.bug.st/serial v1.6.4 + golang.org/x/crypto v0.48.0 ) require ( github.com/creack/goselect v0.1.3 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 36be79b..090d184 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,10 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/assets/web/index.html b/internal/assets/web/index.html index e490eac..d38a700 100644 --- a/internal/assets/web/index.html +++ b/internal/assets/web/index.html @@ -5,6 +5,8 @@ βš–οΈ Scale Daemon | Centro de Control + + diff --git a/internal/assets/web/js/websocket.js b/internal/assets/web/js/websocket.js index 0883379..dc5b4e2 100644 --- a/internal/assets/web/js/websocket.js +++ b/internal/assets/web/js/websocket.js @@ -61,8 +61,9 @@ function connectWebSocket() { // Everything else that parses as JSON but isn't 'ambiente' is treated as weight if (msg && typeof msg === 'object' && msg.tipo === 'ambiente') { handleAmbienteMessage(msg); + } else if (msg && typeof msg === 'object' && msg.tipo === 'error') { + handleServerError(msg); // Handle auth/rate-limit errors } else { - // Valid JSON but not 'ambiente' -> could be quoted weight string handleWeightReading(msg); } }; @@ -126,17 +127,37 @@ function sendMessage(msg) { return true; } -// Send configuration update +// Read the auth token injected by the server into the HTML template +function getAuthToken() { + const meta = document.querySelector('meta[name="ws-auth-token"]'); + return meta ? meta.content : ''; +} + +// Send configuration update (with auth token) function sendConfig() { const config = { tipo: 'config', puerto: el.puertoInput.value || 'COM3', marca: el.marcaSelect.value || 'Rhino BAR 8RS', - modoPrueba: el.modoPruebaCheck.checked + modoPrueba: el.modoPruebaCheck.checked, + auth_token: getAuthToken() }; if (sendMessage(config)) { addLog('SENT', `πŸ“€ Config: ${config.puerto} | ${config.marca} | Prueba: ${config.modoPrueba}`); showToast('ConfiguraciΓ³n enviada', 'info'); } +} + +// Also handle new error responses from the server +// In the onmessage handler, add handling for "error" tipo: +// (Inside handleAmbienteMessage or a new handler) +function handleServerError(msg) { + const errorMessages = { + 'AUTH_INVALID_TOKEN': 'πŸ”’ Token de autenticaciΓ³n invΓ‘lido', + 'RATE_LIMITED': '⏳ Demasiados cambios de configuraciΓ³n. Espere un momento.', + }; + const text = errorMessages[msg.error] || `Error: ${msg.error}`; + addLog('ERROR', text, 'error'); + showToast(text, 'error'); } \ No newline at end of file diff --git a/internal/assets/web/login.html b/internal/assets/web/login.html new file mode 100644 index 0000000..f7da9a3 --- /dev/null +++ b/internal/assets/web/login.html @@ -0,0 +1,86 @@ + + + + + + βš–οΈ Scale Daemon | Login + + + + + +
+
βš–οΈ
+

Scale Daemon

+

+ Ingrese la contraseΓ±a para acceder al panel de control +

+ +
ContraseΓ±a incorrecta
+
Demasiados intentos. Intente mΓ‘s tarde.
+ +
+ + +
+
+ + + \ No newline at end of file diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..466c467 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,217 @@ +// Package auth provides session management, password validation, and brute-force protection. +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "log" + "net/http" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + + "github.com/adcondev/scale-daemon/internal/config" +) + +const ( + // SessionCookieName is the name of the HTTP cookie used for session tokens. + SessionCookieName = "sd_session" + // SessionDuration is how long a session token is valid for. + SessionDuration = 15 * time.Minute + // MaxLoginAttempts is the number of failed login attempts before an IP is locked out. + MaxLoginAttempts = 5 + // LockoutDuration is how long an IP is locked out after MaxLoginAttempts. + LockoutDuration = 5 * time.Minute + // CleanupInterval is how often the cleanup goroutine runs to remove expired sessions and lockouts. + CleanupInterval = 5 * time.Minute +) + +type failInfo struct { + count int + lockedUntil time.Time +} + +// Manager handles session lifecycle, password validation, and login throttling. +// It is safe for concurrent use. +type Manager struct { + sessions map[string]time.Time + failedLogins map[string]failInfo + mu sync.RWMutex +} + +// NewManager creates an auth manager. The cleanup goroutine is bound to ctx +// and will exit cleanly when the context is canceled during service shutdown. +func NewManager(ctx context.Context) *Manager { + m := &Manager{ + sessions: make(map[string]time.Time), + failedLogins: make(map[string]failInfo), + } + go m.cleanupLoop(ctx) + return m +} + +// Enabled returns true if a password hash was injected at build time. +// TODO: When false, all auth checks should be bypassed (dev mode). +func (m *Manager) Enabled() bool { + return config.PasswordHashB64 != "" +} + +// ValidatePassword decodes the base64 hash and compares with bcrypt. +func (m *Manager) ValidatePassword(password string) bool { + if !m.Enabled() { + log.Println("[!] AUTH DISABLED: No password hash configured (dev mode)") + return true + } + + // Decode base64 back to raw bcrypt hash + hashBytes, err := base64.StdEncoding.DecodeString(config.PasswordHashB64) + if err != nil { + log.Printf("[X] Failed to decode password hash from base64: %v", err) + return false + } + + return bcrypt.CompareHashAndPassword(hashBytes, []byte(password)) == nil +} + +// CreateSession generates a cryptographically random session token. +func (m *Manager) CreateSession() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + // crypto/rand failure is catastrophic; fall back to timestamp-based token + log.Printf("[!] crypto/rand failed: %v", err) + return hex.EncodeToString([]byte(time.Now().String())) + } + token := hex.EncodeToString(b) + + m.mu.Lock() + m.sessions[token] = time.Now().Add(SessionDuration) + m.mu.Unlock() + + return token +} + +// ValidateSession checks if a token exists and has not expired. +func (m *Manager) ValidateSession(token string) bool { + if token == "" { + return false + } + + m.mu.RLock() + expiry, exists := m.sessions[token] + m.mu.RUnlock() + + if !exists { + return false + } + + if time.Now().After(expiry) { + m.mu.Lock() + delete(m.sessions, token) + m.mu.Unlock() + return false + } + + return true +} + +// IsLockedOut returns true if the given IP has exceeded MaxLoginAttempts. +func (m *Manager) IsLockedOut(ip string) bool { + m.mu.RLock() + info, exists := m.failedLogins[ip] + m.mu.RUnlock() + + if !exists { + return false + } + return info.count >= MaxLoginAttempts && time.Now().Before(info.lockedUntil) +} + +// RecordFailedLogin increments the failure counter for an IP. +// After MaxLoginAttempts, the IP is locked out for LockoutDuration. +func (m *Manager) RecordFailedLogin(ip string) { + m.mu.Lock() + defer m.mu.Unlock() + + info := m.failedLogins[ip] + info.count++ + if info.count >= MaxLoginAttempts { + info.lockedUntil = time.Now().Add(LockoutDuration) + log.Printf("[AUDIT] IP %s locked out for %v after %d failed login attempts", + ip, LockoutDuration, info.count) + } + m.failedLogins[ip] = info +} + +// ClearFailedLogins resets the counter on successful login. +func (m *Manager) ClearFailedLogins(ip string) { + m.mu.Lock() + delete(m.failedLogins, ip) + m.mu.Unlock() +} + +// SetSessionCookie writes a secure, HttpOnly session cookie. +func (m *Manager) SetSessionCookie(w http.ResponseWriter) string { + token := m.CreateSession() + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: token, + Path: "/", + MaxAge: int(SessionDuration.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + return token +} + +// ClearSessionCookie removes the session cookie from the browser. +func (m *Manager) ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + +// GetSessionFromRequest extracts and validates the session from cookies. +func (m *Manager) GetSessionFromRequest(r *http.Request) bool { + cookie, err := r.Cookie(SessionCookieName) + if err != nil { + return false + } + return m.ValidateSession(cookie.Value) +} + +// cleanupLoop periodically removes expired sessions and stale lockout entries. +// It exits when ctx is cancelled (service shutdown). +func (m *Manager) cleanupLoop(ctx context.Context) { + ticker := time.NewTicker(CleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Println("[i] Auth cleanup goroutine stopped") + return + case <-ticker.C: + m.mu.Lock() + now := time.Now() + for k, v := range m.sessions { + if now.After(v) { + delete(m.sessions, k) + } + } + for k, v := range m.failedLogins { + if v.count >= MaxLoginAttempts && now.After(v.lockedUntil) { + delete(m.failedLogins, k) + } + } + m.mu.Unlock() + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3d8b908..6d3918a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,14 +3,28 @@ package config import ( "fmt" + "log" "sync" ) // Build variables (injected via ldflags) var ( + // ServiceName is the name of the service, used for logging and identification. + ServiceName = "R2k_BasculaServicio_Local" + // BuildEnvironment defines the deployment target (local, remote). BuildEnvironment = "local" - BuildDate = "unknown" - BuildTime = "unknown" + // BuildDate is the date the binary was built. + BuildDate = "unknown" + // BuildTime is the time the binary was built. + BuildTime = "unknown" + // PasswordHashB64 is injected at build time via ldflags. + // It contains a bcrypt hash, NOT the plaintext password. + PasswordHashB64 = "" + // AuthToken is injected at build time via ldflags. + // If empty, config messages are accepted without token validation. + AuthToken = "" + // ServerPort is the default port for the scale service, can be overridden by environment config. + ServerPort = "" ) // Environment holds environment-specific settings @@ -28,15 +42,15 @@ type Environment struct { var Environments = map[string]Environment{ "remote": { Name: "REMOTO", - ServiceName: "R2k_BasculaServicio_Remote", - ListenAddr: "0.0.0.0:8765", + ServiceName: ServiceName, + ListenAddr: "0.0.0.0:" + ServerPort, DefaultPort: "COM3", DefaultMode: false, }, "local": { Name: "LOCAL", - ServiceName: "R2k_BasculaServicio_Local", - ListenAddr: "localhost:8765", + ServiceName: ServiceName, + ListenAddr: "localhost:" + ServerPort, DefaultPort: "COM3", DefaultMode: false, }, @@ -48,7 +62,8 @@ func GetEnvironment(env string) Environment { if cfg, ok := Environments[env]; ok { return cfg } - return Environments["remote"] + log.Printf("[!] Unknown environment '%s', defaulting to 'local'", env) + return Environments["local"] } // Config holds the runtime configuration for the scale service diff --git a/internal/daemon/program.go b/internal/daemon/daemon.go similarity index 92% rename from internal/daemon/program.go rename to internal/daemon/daemon.go index 85f4c88..d63b527 100644 --- a/internal/daemon/program.go +++ b/internal/daemon/daemon.go @@ -12,6 +12,7 @@ import ( "github.com/judwhite/go-svc" + "github.com/adcondev/scale-daemon/internal/auth" "github.com/adcondev/scale-daemon/internal/config" "github.com/adcondev/scale-daemon/internal/logging" "github.com/adcondev/scale-daemon/internal/scale" @@ -33,6 +34,7 @@ type Service struct { reader *scale.Reader broadcaster *server.Broadcaster srv *server.Server + authMgr *auth.Manager // Lifecycle broadcast chan string @@ -80,8 +82,13 @@ func (s *Service) Start() error { s.quit = make(chan struct{}) s.ctx, s.cancel = context.WithCancel(context.Background()) - // Create broadcaster - s.broadcaster = server.NewBroadcaster(s.broadcast) + // Create auth manager (bound to service ctx for clean shutdown) + s.authMgr = auth.NewManager(s.ctx) + + // Create broadcaster with weight activity callback + s.broadcaster = server.NewBroadcaster(s.broadcast, func() { + s.srv.RecordWeightActivity() + }) // Create scale reader s.reader = scale.NewReader(s.cfg, s.broadcast) @@ -93,6 +100,7 @@ func (s *Service) Start() error { s.env, s.broadcaster, s.logMgr, + s.authMgr, buildInfo, s.onConfigChange, s.BuildDate, diff --git a/internal/logging/logging.go b/internal/logging/logging.go index d8cca64..fa56afa 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -77,11 +77,13 @@ func Setup(serviceName string, defaultVerbose bool) (*Manager, error) { mgr.FilePath = filepath.Join(logDir, serviceName+".log") // Try to create log directory + //nolint:gosec if err := os.MkdirAll(logDir, 0750); err != nil { // Permission denied - fallback to stdout (console mode) log.SetOutput(os.Stdout) mgr.FilePath = "" - log.Printf("[i] Logging to stdout (no write access to %s)", logDir) + //nolint:gosec + log.Printf("[i] Logging to stdout (no write access to %q)", logDir) return mgr, nil } diff --git a/internal/server/broadcaster.go b/internal/server/broadcaster.go index 5dfec7d..2cf99a4 100644 --- a/internal/server/broadcaster.go +++ b/internal/server/broadcaster.go @@ -12,16 +12,18 @@ import ( // Broadcaster fans out weight readings to all connected clients type Broadcaster struct { - clients map[*websocket.Conn]bool - mu sync.RWMutex - broadcast <-chan string + clients map[*websocket.Conn]bool + mu sync.RWMutex + broadcast <-chan string + onWeightSent func() // Called after successful weight broadcast } // NewBroadcaster creates a broadcaster for the given channel -func NewBroadcaster(broadcast <-chan string) *Broadcaster { +func NewBroadcaster(broadcast <-chan string, onWeightSent func()) *Broadcaster { return &Broadcaster{ - clients: make(map[*websocket.Conn]bool), - broadcast: broadcast, + clients: make(map[*websocket.Conn]bool), + broadcast: broadcast, + onWeightSent: onWeightSent, } } @@ -50,6 +52,10 @@ func (b *Broadcaster) broadcastWeight(peso string) { } b.mu.RUnlock() + if len(clients) == 0 { + return + } + for _, conn := range clients { go func(c *websocket.Conn) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) @@ -63,6 +69,10 @@ func (b *Broadcaster) broadcastWeight(peso string) { } }(conn) } + // Record activity after broadcasting to at least one client + if b.onWeightSent != nil { + b.onWeightSent() + } } // removeAndCloseClient safely removes and closes a client connection diff --git a/internal/server/models.go b/internal/server/models.go index aa12396..5e561d8 100644 --- a/internal/server/models.go +++ b/internal/server/models.go @@ -1,16 +1,25 @@ package server -// ConfigMessage matches the exact JSON structure from clients -// CONSTRAINT: All fields must match legacy format exactly +// ConfigMessage matches the exact JSON structure from clients. +// AuthToken is required when AuthToken is set at build time. +// CONSTRAINT: All fields must match legacy format exactly. JSON fields can't be changed or be removed. type ConfigMessage struct { Tipo string `json:"tipo"` Puerto string `json:"puerto"` Marca string `json:"marca"` ModoPrueba bool `json:"modoPrueba"` Dir string `json:"dir,omitempty"` + //nolint:gosec + AuthToken string `json:"auth_token"` // Required for config changes } -// EnvironmentInfo sent to clients on connection +// ErrorResponse is sent back to clients when an operation is rejected +type ErrorResponse struct { + Tipo string `json:"tipo"` + Error string `json:"error"` +} + +// EnvironmentInfo is sent to clients on connection type EnvironmentInfo struct { Tipo string `json:"tipo"` Ambiente string `json:"ambiente"` diff --git a/internal/server/rate_limit.go b/internal/server/rate_limit.go new file mode 100644 index 0000000..fd79667 --- /dev/null +++ b/internal/server/rate_limit.go @@ -0,0 +1,47 @@ +package server + +import ( + "sync" + "time" +) + +// ConfigRateLimiter restricts how frequently a single client +// can send config change requests via WebSocket. +type ConfigRateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + maxPerMin int +} + +// NewConfigRateLimiter creates a limiter allowing maxPerMinute config changes per client. +func NewConfigRateLimiter(maxPerMinute int) *ConfigRateLimiter { + return &ConfigRateLimiter{ + attempts: make(map[string][]time.Time), + maxPerMin: maxPerMinute, + } +} + +// Allow returns true if the client has not exceeded the rate limit. +// It prunes old entries on every call. +func (rl *ConfigRateLimiter) Allow(clientAddr string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + cutoff := now.Add(-time.Minute) + + // Keep only entries within the window + recent := make([]time.Time, 0, rl.maxPerMin) + for _, t := range rl.attempts[clientAddr] { + if t.After(cutoff) { + recent = append(recent, t) + } + } + + if len(recent) >= rl.maxPerMin { + return false + } + + rl.attempts[clientAddr] = append(recent, now) + return true +} diff --git a/internal/server/server.go b/internal/server/server.go index 43a394b..ab92e9d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,10 +1,12 @@ -// Package server implements the HTTP and WebSocket server for the scale daemon. +// Package server handles WebSocket connections, HTTP endpoints, and configuration updates for the R2k Ticket Servicio dashboard. package server import ( "context" "encoding/json" "errors" + "fmt" + "html/template" "io" "io/fs" "log" @@ -15,18 +17,23 @@ import ( "github.com/coder/websocket" "github.com/coder/websocket/wsjson" + "github.com/adcondev/scale-daemon/internal/auth" "github.com/adcondev/scale-daemon/internal/config" "github.com/adcondev/scale-daemon/internal/logging" - "github.com/adcondev/scale-daemon" + embedded "github.com/adcondev/scale-daemon" ) +const maxConfigChangesPerMinute = 15 + // Server handles HTTP and WebSocket connections type Server struct { config *config.Config env config.Environment broadcaster *Broadcaster logMgr *logging.Manager + auth *auth.Manager + configLimiter *ConfigRateLimiter buildInfo string onConfigChange func() buildDate string @@ -35,6 +42,7 @@ type Server struct { mu sync.RWMutex lastWeightTime time.Time httpServer *http.Server + dashboardTmpl *template.Template } // NewServer creates a new server instance @@ -43,6 +51,7 @@ func NewServer( env config.Environment, broadcaster *Broadcaster, logMgr *logging.Manager, + authMgr *auth.Manager, buildInfo string, onConfigChange func(), buildDate string, @@ -54,6 +63,8 @@ func NewServer( env: env, broadcaster: broadcaster, logMgr: logMgr, + auth: authMgr, + configLimiter: NewConfigRateLimiter(maxConfigChangesPerMinute), // Max 15 config changes per minute per client buildInfo: buildInfo, onConfigChange: onConfigChange, buildDate: buildDate, @@ -61,25 +72,43 @@ func NewServer( startTime: startTime, } - // Setup HTTP handlers - mux := http.NewServeMux() - mux.HandleFunc("/ws", s.handleWebSocket) - mux.HandleFunc("/health", s.HandleHealth) - mux.HandleFunc("/ping", s.HandlePing) - - // Setup FS + // Setup embedded filesystem webFS, err := fs.Sub(embedded.WebFiles, "internal/assets/web") if err != nil { - // Panic is acceptable here as service cannot function without assets log.Fatalf("[FATAL] Error loading web assets: %v", err) } - mux.Handle("/", http.FileServer(http.FS(webFS))) - // This ensures s.httpServer is NEVER nil once NewServer returns + // Parse index.html as a Go template for token injection + indexBytes, err := fs.ReadFile(webFS, "index.html") + if err != nil { + log.Fatalf("[FATAL] Error reading index.html: %v", err) + } + s.dashboardTmpl, err = template.New("dashboard").Parse(string(indexBytes)) + if err != nil { + log.Fatalf("[FATAL] Error parsing index.html as template: %v", err) + } + + // Setup HTTP handlers with correct auth boundaries + mux := http.NewServeMux() + + // ── PUBLIC ROUTES (no auth required) ───────────────────── + // Static assets must be public so login.html can load CSS + mux.Handle("/css/", http.FileServer(http.FS(webFS))) + mux.Handle("/js/", http.FileServer(http.FS(webFS))) + mux.HandleFunc("/login", s.serveLoginPage(webFS)) + mux.HandleFunc("/auth/login", s.handleLogin) + mux.HandleFunc("/auth/logout", s.handleLogout) + mux.HandleFunc("/ping", s.HandlePing) + mux.HandleFunc("/ws", s.handleWebSocket) + mux.HandleFunc("/health", s.HandleHealth) + + // ── PROTECTED ROUTES (session required) ────────────────── + + mux.HandleFunc("/", s.requireAuth(s.serveDashboard)) + s.httpServer = &http.Server{ - Addr: env.ListenAddr, - Handler: mux, - // ALWAYS add timeouts to prevent Slowloris attacks + Addr: env.ListenAddr, + Handler: mux, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, @@ -88,17 +117,119 @@ func NewServer( return s } -// handleWebSocket upgrades the connection -func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { - // No need to check "Upgrade" header here manually; - // clients connecting to /ws likely intend to upgrade. +// ═══════════════════════════════════════════════════════════════ +// AUTH MIDDLEWARE & HANDLERS +// ═══════════════════════════════════════════════════════════════ + +// requireAuth wraps a HandlerFunc with session validation. +// If auth is disabled (no hash), all requests pass through. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.auth.Enabled() { + next(w, r) + return + } + if !s.auth.GetSessionFromRequest(r) { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next(w, r) + } +} + +// serveLoginPage returns a handler that serves login.html from the embedded FS. +func (s *Server) serveLoginPage(webFS fs.FS) http.HandlerFunc { + loginHTML, err := fs.ReadFile(webFS, "login.html") + if err != nil { + log.Fatalf("[FATAL] Error reading login.html: %v", err) + } + return func(w http.ResponseWriter, r *http.Request) { + // If auth is disabled, skip login entirely + if !s.auth.Enabled() { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + // If already authenticated, redirect to dashboard + if s.auth.GetSessionFromRequest(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(loginHTML) + } +} +// serveDashboard renders index.html as a Go template, injecting the config auth token. +// This solves the "static file injection paradox": index.html is a template, not a static file. +func (s *Server) serveDashboard(w http.ResponseWriter, r *http.Request) { + // Only serve dashboard for root path (avoid catching /favicon.ico etc.) + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + data := struct { + //nolint:gosec + AuthToken string + }{ + AuthToken: config.AuthToken, + } + if err := s.dashboardTmpl.Execute(w, data); err != nil { + log.Printf("[X] Error rendering dashboard template: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// handleLogin processes POST /auth/login +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ip := r.RemoteAddr + + // Check lockout FIRST + if s.auth.IsLockedOut(ip) { + //nolint:gosec + log.Printf("[AUDIT] LOGIN_BLOCKED | IP=%q | reason=lockout", ip) + http.Redirect(w, r, "/login?locked=1", http.StatusSeeOther) + return + } + + password := r.FormValue("password") + if !s.auth.ValidatePassword(password) { + s.auth.RecordFailedLogin(ip) + //nolint:gosec + log.Printf("[AUDIT] LOGIN_FAILED | IP=%q", ip) + http.Redirect(w, r, "/login?error=1", http.StatusSeeOther) + return + } + + // Success + s.auth.ClearFailedLogins(ip) + s.auth.SetSessionCookie(w) + //nolint:gosec + log.Printf("[AUDIT] LOGIN_SUCCESS | IP=%q", ip) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// handleLogout clears the session +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + s.auth.ClearSessionCookie(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// ═══════════════════════════════════════════════════════════════ +// WEBSOCKET +// ═══════════════════════════════════════════════════════════════ + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - // FIXME: InsecureSkipVerify should be false in production with proper certs InsecureSkipVerify: true, - OriginPatterns: []string{"*"}, + OriginPatterns: s.allowedOrigins(), }) - if err != nil { log.Printf("[X] Error accepting websocket: %v", err) return @@ -112,21 +243,25 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - // Register client s.broadcaster.AddClient(c) log.Printf("[+] Client connected (Total: %d)", s.broadcaster.ClientCount()) - // Send initial state s.sendEnvironmentInfo(ctx, c) - - // Listen for incoming config messages s.listenForMessages(ctx, c) - // Cleanup s.broadcaster.RemoveClient(c) log.Println("[-] Client disconnected") } +// allowedOrigins returns environment-specific WebSocket origin patterns. +func (s *Server) allowedOrigins() []string { + if s.env.Name == "LOCAL" { + return []string{"localhost:*", "127.0.0.1:*"} + } + // Remote: allow common private network ranges + return []string{"192.168.*.*:*", "10.*.*.*:*", "172.16.*.*:*", "localhost:*"} +} + func (s *Server) sendEnvironmentInfo(ctx context.Context, c *websocket.Conn) { conf := s.config.Get() @@ -180,7 +315,15 @@ func (s *Server) listenForMessages(ctx context.Context, c *websocket.Conn) { func (s *Server) handleMessage(ctx context.Context, c *websocket.Conn, tipo string, mensaje map[string]interface{}) { switch tipo { case "config": - s.handleConfigMessage(mensaje) + // ── RATE LIMIT CHECK ───────────────────────────────── + // Use connection pointer address as unique client identifier + clientAddr := fmt.Sprintf("%p", c) + if !s.configLimiter.Allow(clientAddr) { + log.Printf("[AUDIT] CONFIG_RATE_LIMITED | client=%s", clientAddr) + s.sendJSON(ctx, c, ErrorResponse{Tipo: "error", Error: "RATE_LIMITED"}) + return + } + s.handleConfigMessage(ctx, c, mensaje) case "logConfig": if v, ok := mensaje["verbose"].(bool); ok { @@ -215,17 +358,24 @@ func (s *Server) handleMessage(ctx context.Context, c *websocket.Conn, tipo stri } } -func (s *Server) handleConfigMessage(mensaje map[string]interface{}) { +func (s *Server) handleConfigMessage(ctx context.Context, c *websocket.Conn, mensaje map[string]interface{}) { // Parse into struct for type safety data, _ := json.Marshal(mensaje) var configMsg ConfigMessage - err := json.Unmarshal(data, &configMsg) - if err != nil { + if err := json.Unmarshal(data, &configMsg); err != nil { log.Printf("[X] Error parsing config message: %v", err) return } - log.Printf("[i] ConfiguraciΓ³n recibida: Puerto=%s Marca=%s ModoPrueba=%v", + // ── TOKEN VALIDATION ───────────────────────────────────── + if config.AuthToken != "" && configMsg.AuthToken != config.AuthToken { + log.Printf("[AUDIT] CONFIG_REJECTED | reason=invalid_token | puerto=%s marca=%s", + configMsg.Puerto, configMsg.Marca) + s.sendJSON(ctx, c, ErrorResponse{Tipo: "error", Error: "AUTH_INVALID_TOKEN"}) + return + } + + log.Printf("[AUDIT] CONFIG_ACCEPTED | puerto=%s marca=%s modoPrueba=%v", configMsg.Puerto, configMsg.Marca, configMsg.ModoPrueba) if s.config.Update(configMsg.Puerto, configMsg.Marca, configMsg.ModoPrueba) { @@ -239,28 +389,26 @@ func (s *Server) handleConfigMessage(mensaje map[string]interface{}) { } } -// HandlePing is a lightweight liveness check +// ═══════════════════════════════════════════════════════════════ +// HTTP ENDPOINTS +// ═══════════════════════════════════════════════════════════════ + +// HandlePing responds with "pong" for health checks. func (s *Server) HandlePing(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte("pong")) - if err != nil { - return - } + _, _ = w.Write([]byte("pong")) } -// HandleHealth returns service health metrics +// HandleHealth returns service health and scale connection status. func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) { cfg := s.config.Get() - // If last weight was received < 15 seconds ago, assume connected. - // Adjust threshold based on your poll interval. s.mu.RLock() isConnected := !s.lastWeightTime.IsZero() && time.Since(s.lastWeightTime) < 15*time.Second s.mu.RUnlock() - // If in Test Mode, we are always "connected" to the generator if cfg.ModoPrueba { isConnected = true } @@ -278,43 +426,36 @@ func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) { Date: s.buildDate, Time: s.buildTime, }, - // Safe uptime calculation Uptime: int(time.Since(s.startTime).Seconds()), } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") - err := json.NewEncoder(w).Encode(response) - if err != nil { - return - } + _ = json.NewEncoder(w).Encode(response) } func (s *Server) sendJSON(ctx context.Context, c *websocket.Conn, v interface{}) { - // Record activity whenever we successfully send data (e.g. weight updates) - s.recordWeightActivity() - ctx2, cancel := context.WithTimeout(ctx, time.Second) defer cancel() _ = wsjson.Write(ctx2, c, v) } -func (s *Server) recordWeightActivity() { +// RecordWeightActivity updates the last weight timestamp for health checks. +func (s *Server) RecordWeightActivity() { s.mu.Lock() defer s.mu.Unlock() s.lastWeightTime = time.Now() } -// ListenAndServe inicia el servidor HTTP +// ListenAndServe starts the HTTP server and logs the active endpoints and auth status. func (s *Server) ListenAndServe() error { log.Printf("[i] Dashboard active at http://%s/", s.env.ListenAddr) log.Printf("[i] WebSocket active at ws://%s/ws", s.env.ListenAddr) - - // Just start the already-configured server + log.Printf("[i] Auth enabled: %v", s.auth.Enabled()) return s.httpServer.ListenAndServe() } -// Shutdown gracefully shuts down the HTTP server +// Shutdown gracefully shuts down the HTTP server with a timeout context. func (s *Server) Shutdown(ctx context.Context) error { if s.httpServer == nil { return errors.New("server Shutdown called with nil httpServer; invariant violated")