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")