Nest runs class and method guards together, so a class gate naming only the view key silently required view AND action. Staff granted just an action were denied before their key was checked. Each class gate now names every key its routes use, and FleetView accepts an array so the fleet controllers keep their coarse fallback. Drops the one-off grant mapping SQL with it: already applied to dev, and this fix removes the companion-view rule that was its recurring part.
GPS Tracking (GT06) — Operations & Device Configuration
GT06 trackers speak a raw TCP binary protocol, not HTTP/HTTPS. This shapes everything about how the service is deployed and how devices are pointed at it.
1. Why GPS needs its own dedicated TCP port
- Not HTTP. GT06 devices send binary frames
(
0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A). An HTTP server receiving these answers400 Bad Requestand closes. - Dedicated port required. A listening socket is keyed on
(IP, port); two listeners on the same pair collide (EADDRINUSE). The REST API already owns its port, so GPS traffic needs a separate one. - No hostname routing. GT06 frames carry no
Hostheader and no TLS SNI, so L7 proxies (Nginxhttp, AWS ALB, Cloudflare proxy) cannot route them by domain. Routing must happen at Layer 4 (TCP) by port. - DNS carries no port. An A record maps a name to an IP only. The tracker
config must state the port explicitly (e.g.
gps.example.com:5023).
Operational requirements
| Item | Value |
|---|---|
| Protocol | Raw TCP (not HTTP, not TLS) |
| Default port | 5023 (configurable via GT06_TCP_PORT) |
| Listener bind | 0.0.0.0 inside the freight-gps container |
| Edge terminator | L4 — AWS NLB or Nginx stream {}. Not ALB / Cloudflare proxy. |
2. Port configuration
5023 is only this project's default — not a GT06 protocol requirement. The
listener binds whatever GT06_TCP_PORT says, as long as trackers are configured
with the same number.
Host and container ports are decoupled in docker-compose.yaml:
freight-gps:
ports:
- "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed
environment:
GT06_TCP_PORT: "5023" # pinned inside the container
- The container always listens on
5023. - The host/public port is configurable (443, 5023, 9000, …) via the root
.env'sGT06_TCP_PORT. - This split is required because the image runs as a non-root user
(
nestjs, uid 1001), which cannot bind ports<1024. Docker (root) binds the host port and forwards to5023inside. - Running outside Docker (
pnpm dev:gps, systemd),GT06_TCP_PORTis the actual bind port, so<1024needs root orCAP_NET_BIND_SERVICE. - 443 is allowed but risky: GT06 stays raw TCP, not TLS. Middleboxes that expect a TLS handshake on 443 may drop the connection.
3. Deployment topology
The GT06 listener runs as its own process (dist/main.gps.js, module
GpsIngestModule) — DB + GPS only, no HTTP server. It shares the edr_freight
DB with the API; the DB is the seam (ingester writes gps_devices /
gps_positions, API reads them).
freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations)
freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket)
DB_MIGRATIONS_RUN=false keeps the second process from racing migrations.
Horizontal scale: each tracker holds one long-lived TCP connection with
per-socket session state, so N freight-gps replicas can run behind an L4 LB —
each device sticks to one replica. ensureDevice is safe under concurrency
(unique IMEI).
4. Device configuration (GT06 side)
Config is done by SMS to the tracker's SIM. Commands below are the canonical
Concox/GT06 set — verify against your unit's sheet, syntax varies by firmware.
Default command password is usually 123456.
Prep: data-enabled SIM, SMS on, SIM PIN off, know your carrier APN.
STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS
APN,<apn># # 2. carrier data APN (add ,user,pass if needed)
SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT
GPRSON,1# # 4. enable data
GPSON,1# # enable GPS
TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#)
RESET# # 6. reboot so it reconnects (many cache DNS until reboot)
Raw-IP variant of step 3: SERVER,0,203.0.113.50,5023,0#
Custom host port (e.g. 443): SERVER,1,gps.example.com,443,0#
Verify from the server
docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up"
nc -vz gps.example.com 5023
curl -H "Authorization: Bearer <token>" https://api.example.com/api/gps/positions/latest
First login packet auto-registers the IMEI (no manual step). online:true
only when lastSeenAt < 5 min (computed at read time).
Link a tracker to a vehicle (optional)
Auto-register leaves vehicleId null. Attach it (needs tracking.manage):
PATCH /api/gps/devices/:id { "vehicleId": "<uuid>", "name": "Truck 03-ET" }
Failure map
| Symptom | Cause |
|---|---|
| No SMS reply | SIM PIN on / no signal / wrong number |
| Replies but never connects | APN wrong, or SERVER port ≠ GT06_TCP_PORT |
| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS |
Registered but online:false |
packets blocked by firewall — open inbound TCP |
Wrong location / positioned:false |
no GPS fix yet — open sky, cold start ~1–2 min |
5. Security
- GT06 authenticates with IMEI only, which is spoofable. Anyone who can reach the port can inject fake positions.
- Do not expose the port to
0.0.0.0/0. Restrict at the firewall / security group to the SIM provider's APN / IP range. - Trackers must use the same host+port as the server:
SERVER,1,gps.example.com,<port>,0#.
6. Edge (L4) termination
See infrastructure/nginx/gps-stream.conf
for an Nginx stream {} example, and the AWS NLB notes in the same file.
Reminder: L4 only — an HTTP proxy cannot route GT06.