mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-09-02 00:58:22 +00:00
chore: release v1.0.0
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Beacon Contributors
|
||||
# SPDX-License-Identifier: agpl
|
||||
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Copyright 2026 Beacon Contributors
|
||||
# SPDX-License-Identifier: agpl
|
||||
|
||||
set -e
|
||||
|
||||
if [ -n "$POSTGRES_DSN" ]; then
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something isn't working as expected
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## What happened
|
||||
|
||||
A clear description of the bug.
|
||||
|
||||
## Expected behaviour
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Environment
|
||||
|
||||
- Beacon version / commit:
|
||||
- Go version:
|
||||
- PostgreSQL version:
|
||||
- OS:
|
||||
- Deployment method (Docker / bare metal):
|
||||
|
||||
## Relevant logs or output
|
||||
|
||||
```
|
||||
paste logs here
|
||||
```
|
||||
|
||||
## Additional context
|
||||
|
||||
Any other context — config snippets (redact credentials), MQTT broker details,
|
||||
mesh topology, etc.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a new feature or improvement
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
A brief description of what you'd like to see.
|
||||
|
||||
## Problem or motivation
|
||||
|
||||
What problem does this solve, or what use case does it enable? If this is
|
||||
related to an existing issue or limitation, link it here.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
How you'd like it to work. API shape, config options, WS event format — as much
|
||||
detail as you have.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Any other approaches you considered and why you ruled them out.
|
||||
|
||||
## Additional context
|
||||
|
||||
Screenshots, diagrams, links to relevant MeshCore protocol docs, etc.
|
||||
@@ -0,0 +1,30 @@
|
||||
## What this PR does
|
||||
|
||||
A clear description of the change and why it was made. Reference any related
|
||||
issues: `Closes #123` or `Related to #456`.
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor
|
||||
- [ ] Tests
|
||||
- [ ] Docs / config
|
||||
- [ ] Other:
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `go build ./...` passes
|
||||
- [ ] `gofmt -l .` is empty
|
||||
- [ ] `go vet ./...` passes
|
||||
- [ ] `go test ./...` passes
|
||||
- [ ] New pure functions have unit tests
|
||||
- [ ] DB changes include migration SQL and `sqlc generate` has been run
|
||||
- [ ] API changes include swagger annotations and `swag init` has been run
|
||||
- [ ] `docs/` is committed if swagger was regenerated
|
||||
- [ ] New dependencies are added to `SHOULDERS.md`
|
||||
- [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
## Testing notes
|
||||
|
||||
How did you test this? What should reviewers look for?
|
||||
@@ -1,8 +1,10 @@
|
||||
# Copyright 2026 Beacon Contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main,dev]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -32,3 +34,16 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
- name: Install swag
|
||||
run: go install github.com/swaggo/swag/cmd/swag@latest
|
||||
|
||||
- name: Swagger docs up to date
|
||||
run: |
|
||||
swag init -g cmd/beacon/main.go -o docs --parseInternal --parseDependency
|
||||
if [ -n "$(git diff --name-only docs/)" ]; then
|
||||
echo "Swagger docs are out of date. Run swag init and commit the result."
|
||||
git diff --name-only docs/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Beacon Contributors
|
||||
# SPDX-License-Identifier: agpl
|
||||
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and maintainers of the Beacon project pledge to
|
||||
make participation in our community a harassment-free experience for everyone,
|
||||
regardless of age, body size, visible or invisible disability, ethnicity, sex
|
||||
characteristics, gender identity and expression, level of experience, education,
|
||||
socioeconomic status, nationality, personal appearance, race, caste, colour,
|
||||
religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behaviour that contributes to a positive environment:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy and kindness toward other community members
|
||||
- Acknowledging and crediting the contributions of others
|
||||
|
||||
Examples of unacceptable behaviour:
|
||||
|
||||
- Hate speech or discriminatory language or jokes of any kind — including but
|
||||
not limited to those targeting race, ethnicity, nationality, gender identity
|
||||
or expression, sexual orientation, disability, religion, age, or socioeconomic
|
||||
background
|
||||
- The use of sexualised language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment of any kind
|
||||
- Deliberate misgendering or use of rejected names
|
||||
- Publishing others' private information — such as a physical or email address —
|
||||
without their explicit permission
|
||||
- Threats of violence or incitement of violence toward any individual or group
|
||||
- Dismissing or minimising reports of harassment or discrimination
|
||||
- Any other conduct which could reasonably be considered inappropriate in a
|
||||
professional or community setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Maintainers are responsible for clarifying and enforcing our standards of
|
||||
acceptable behaviour and will take appropriate and fair corrective action in
|
||||
response to any behaviour that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, issues, and other contributions that are not aligned
|
||||
with this Code of Conduct, and will communicate reasons for moderation decisions
|
||||
when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces — GitHub issues, pull
|
||||
requests, discussions, and the MeshCore Canada Discord server — and also when an
|
||||
individual is representing the project in public spaces.
|
||||
|
||||
## Reporting
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behaviour may be
|
||||
reported by contacting the maintainers directly on the MeshCore Canada Discord
|
||||
server at https://discord.gg/Gz3KvJx2hf — reach out to **dedskelly** privately.
|
||||
|
||||
All reports will be handled with discretion and confidentiality. Maintainers are
|
||||
obligated to respect the privacy and safety of the reporter.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Maintainers will follow these guidelines when determining consequences for
|
||||
behaviour that violates this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Impact:** Use of inappropriate language or other behaviour deemed unwelcome.
|
||||
|
||||
**Consequence:** A private written warning explaining the nature of the
|
||||
violation and why the behaviour was inappropriate. A public apology may be
|
||||
requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Impact:** A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence:** A warning with consequences for continued behaviour. No
|
||||
interaction with the people involved for a specified period. This includes
|
||||
avoiding interaction in community spaces as well as external channels. Violating
|
||||
these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Impact:** A serious violation of community standards, including sustained
|
||||
inappropriate behaviour or a pattern of harassment.
|
||||
|
||||
**Consequence:** A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period. No public or private
|
||||
interaction with the people involved is allowed during this period. Violating
|
||||
these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Impact:** Demonstrating a pattern of violation of community standards,
|
||||
including sustained harassment, hate speech, or aggression toward or
|
||||
discrimination against any individual or group.
|
||||
|
||||
**Consequence:** A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org), version 2.1,
|
||||
available at
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
+217
-26
@@ -1,51 +1,242 @@
|
||||
# Contributing to Beacon
|
||||
|
||||
Thank you for your interest in contributing. Beacon is a focused project and we
|
||||
want contributions to be high quality and sustainable. Please read this guide
|
||||
before opening a PR.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
**Open or comment on an issue before starting work.** This avoids duplicate
|
||||
effort and lets maintainers flag if something is already in progress or out of
|
||||
scope. For small bug fixes a brief comment is fine; for larger features please
|
||||
discuss the approach first.
|
||||
|
||||
**One thing per PR.** Each pull request should cover one logical change — a bug
|
||||
fix, a new endpoint, a refactor, a new test. PRs that touch many unrelated parts
|
||||
of the codebase are hard to review and hard to revert if something goes wrong.
|
||||
|
||||
**No fully AI-generated contributions.** We welcome developers who use AI tools
|
||||
to assist their work, but PRs should reflect the author's own understanding and
|
||||
judgement. PRs that appear to be unreviewed AI output may be closed without
|
||||
further comment.
|
||||
|
||||
---
|
||||
|
||||
## Branches
|
||||
|
||||
- `main` — stable releases only, protected
|
||||
- `dev` — active development, all PRs target this branch
|
||||
- `main` — stable releases only, protected. Never target this directly.
|
||||
- `dev` — active development. All PRs target `dev`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Fork or create a branch from `dev`
|
||||
2. Make your changes
|
||||
3. Open a pull request against `dev`
|
||||
4. One commit per PR (squash before opening or use squash merge)
|
||||
3. Run the checklist below
|
||||
4. Open a pull request against `dev` with a clear description of what changed
|
||||
and why, referencing any related issues
|
||||
|
||||
---
|
||||
|
||||
## Checklist before opening a PR
|
||||
|
||||
```
|
||||
go build ./... # must compile
|
||||
gofmt -l . # must be empty (no unformatted files)
|
||||
go vet ./... # no warnings
|
||||
go test ./... # all tests pass
|
||||
swag init # if you changed any handler or api type (see below)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code style
|
||||
|
||||
- Run `gofmt -w .` before committing — CI will fail on unformatted files
|
||||
- Run `go vet ./...` — no warnings
|
||||
- Follow the existing patterns in each package before introducing new ones
|
||||
- Keep functions small and single-purpose
|
||||
- Prefer explicit error handling over panic
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **Add tests for any new pure functions.** Pure functions (no DB, no network,
|
||||
no side effects) should have unit tests. See `internal/hub/hub_test.go`,
|
||||
`internal/api/nodes_test.go`, and `internal/keystore/keystore_test.go` for
|
||||
examples of the style we use.
|
||||
- Integration tests (requiring a real DB) are not yet required but are welcome.
|
||||
They will be gated before release.
|
||||
- Run `go test ./...` before opening a PR. All tests must pass.
|
||||
- If you are fixing a bug, add a test that would have caught it.
|
||||
|
||||
---
|
||||
|
||||
## Database changes
|
||||
|
||||
All schema changes must include a proper migration path:
|
||||
|
||||
- Add SQL to `db/migrations/001_schema.sql` (we use a single migration file for
|
||||
now — append to the appropriate section with a comment)
|
||||
- Update `db/queries/queries.sql` with any new or modified queries
|
||||
- Re-run `sqlc generate` to regenerate `db/sqlc/`
|
||||
- Update the store layer in `db/` to expose the new functionality
|
||||
- Update `internal/api/reader.go` if the change needs to be exposed via the API
|
||||
|
||||
Never edit files under `db/sqlc/` by hand — they are generated by sqlc and will
|
||||
be overwritten. If you need to work around a sqlc limitation, document it
|
||||
clearly in the query comment.
|
||||
|
||||
To regenerate after modifying `db/queries/queries.sql`:
|
||||
|
||||
```bash
|
||||
sqlc generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API changes
|
||||
|
||||
Any new or modified REST endpoint must have swagger annotations and regenerated
|
||||
docs:
|
||||
|
||||
- Add or update `// @Summary`, `// @Param`, `// @Success`, `// @Failure`, and
|
||||
`// @Router` comments on the handler function
|
||||
- Response types are defined in `internal/api/` — add new types there, not
|
||||
inline in handlers
|
||||
- After changing any handler or API type, regenerate the swagger docs and commit
|
||||
the updated `docs/` directory alongside your changes:
|
||||
|
||||
```bash
|
||||
swag init -g cmd/beacon/main.go -o docs --parseInternal --parseDependency
|
||||
```
|
||||
|
||||
Install swag if you don't have it:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
```
|
||||
|
||||
Each handler function should have a godoc-style annotation block:
|
||||
|
||||
```go
|
||||
// listThings godoc
|
||||
//
|
||||
// @Summary Short description shown in the UI
|
||||
// @Tags TagName
|
||||
// @Produce json
|
||||
// @Param paramName query string false "Description"
|
||||
// @Param id path string true "Resource ID"
|
||||
// @Success 200 {object} api.MyResponseType
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Router /things [get]
|
||||
func listThings(reader api.Reader) http.HandlerFunc {
|
||||
```
|
||||
|
||||
For paginated responses use the generic page wrapper:
|
||||
|
||||
```go
|
||||
// @Success 200 {object} api.Page[api.MyType]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Updating the IATA database
|
||||
|
||||
Beacon includes a static IATA → country/continent mapping compiled into the
|
||||
binary, generated from the [OurAirports](https://ourairports.com/data/) public
|
||||
dataset.
|
||||
|
||||
To refresh it with the latest airport data:
|
||||
|
||||
```bash
|
||||
rm internal/iatadb/gen/airports.csv
|
||||
go generate ./internal/iatadb/
|
||||
```
|
||||
|
||||
This fetches a fresh `airports.csv` from OurAirports, saves it locally, and
|
||||
regenerates `internal/iatadb/db.go`. Commit both files.
|
||||
|
||||
To use a local CSV instead (e.g. in a restricted network environment):
|
||||
|
||||
```bash
|
||||
AIRPORTS_CSV=/path/to/airports.csv go run ./internal/iatadb/gen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a new dependency
|
||||
|
||||
- Run `go get <module>` and `go mod tidy`
|
||||
- Add an entry to [SHOULDERS.md](SHOULDERS.md) with a brief description of what
|
||||
the dependency does and why it was added
|
||||
|
||||
---
|
||||
|
||||
## Commit messages
|
||||
|
||||
Use the conventional commits format:
|
||||
|
||||
- `feat: add route search endpoint`
|
||||
- `fix: correct lat/lon divisor for advert payloads`
|
||||
- `chore: update airports.csv`
|
||||
- `docs: update README project layout`
|
||||
```
|
||||
feat(routes): add observation_count to known routes response
|
||||
fix(ingest): correct lat/lon divisor for advert payloads
|
||||
refactor(hub): collapse RegionIATAs into IATAs in Scope
|
||||
test(api): add unit tests for NodeTypeName and NodeTypeFromString
|
||||
chore: update airports.csv
|
||||
docs: expand CONTRIBUTING.md
|
||||
```
|
||||
|
||||
## Code style
|
||||
Scopes are optional but helpful for larger codebases. Common scopes: `api`,
|
||||
`db`, `ingest`, `hub`, `ws`, `handlers`, `config`, `keystore`.
|
||||
|
||||
- Run `gofmt -w .` before committing
|
||||
- Run `go vet ./...` — no warnings
|
||||
- Run `go build ./...` — must compile
|
||||
---
|
||||
|
||||
## Tests
|
||||
## Project structure
|
||||
|
||||
- Add tests for any new pure functions
|
||||
- Run `go test ./...` before opening a PR
|
||||
- Integration tests are not yet required but welcome
|
||||
```
|
||||
cmd/beacon/ — main entry point, wiring, startup
|
||||
db/ — store layer: sqlc-generated code + thin mapping layer
|
||||
migrations/ — SQL schema (single file, append only)
|
||||
queries/ — SQL queries (input to sqlc)
|
||||
sqlc/ — generated Go code (do not edit by hand)
|
||||
internal/
|
||||
api/ — response types and Reader interface
|
||||
handlers/ — HTTP handlers (validation, routing, response)
|
||||
router/ — chi router wiring
|
||||
config/ — config loading and scope key derivation
|
||||
hub/ — WebSocket fan-out broker
|
||||
ingest/ — MQTT packet ingestion and side effects
|
||||
iatadb/ — in-memory IATA airport lookup
|
||||
keystore/ — channel key lookup
|
||||
scopestore/ — transport scope key lookup
|
||||
ws/ — WebSocket connection handling
|
||||
docs/ — generated swagger docs (do not edit by hand)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
Key patterns to understand before contributing:
|
||||
|
||||
When adding a new dependency please add it to [SHOULDERS.md](SHOULDERS.md) with
|
||||
a brief description of what it does.
|
||||
- **Store layer** (`db/`): thin wrappers around sqlc-generated queries. Each
|
||||
method maps between the ingest/api param structs and sqlc param structs. Never
|
||||
put business logic here.
|
||||
- **Ingest layer** (`internal/ingest/`): processes raw MQTT packets, calls the
|
||||
store, and broadcasts hub events. The `DB` interface in `ingest.go` defines
|
||||
exactly what the ingest layer needs from the store — keep it minimal.
|
||||
- **Hub** (`internal/hub/`): pure fan-out broker. Events are pre-serialised JSON
|
||||
before entering the hub so the broadcast loop never touches encoding.
|
||||
- **Reader interface** (`internal/api/reader.go`): defines everything the API
|
||||
layer can read. The store implements it. All handler tests use a stub reader.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Target `dev` not `main`
|
||||
- One logical change per PR
|
||||
- Include a brief description of what changed and why
|
||||
- Reference any related issues
|
||||
---
|
||||
|
||||
## Releases
|
||||
|
||||
Merges from `dev` to `main` are done by maintainers and represent a versioned
|
||||
release.
|
||||
release. Do not open PRs directly against `main`.
|
||||
|
||||
## Recognition
|
||||
|
||||
If you'd like to be listed as a contributor, add yourself to
|
||||
[CONTRIBUTORS.md](CONTRIBUTORS.md) in your PR.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Contributors
|
||||
|
||||
Beacon is built by its contributors. Thank you to everyone who has helped.
|
||||
|
||||
## Core
|
||||
|
||||
- [ded](https://hackers.town/@ded) — co-founder, lead developer
|
||||
|
||||
## Contributors
|
||||
|
||||
Contributors who wish to be listed here may add themselves in a PR.
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2026 Beacon Contributors
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -4,12 +4,15 @@ MeshCore Beacon is a MeshCore network observation backend. It connects to one or
|
||||
more MeshCore MQTT brokers, ingests LoRa packet traffic in real time, stores it
|
||||
in PostgreSQL, and streams live events to WebSocket clients.
|
||||
|
||||
[](https://github.com/MeshCore-Beacon/beacon-server/actions/workflows/ci.yml)
|
||||
[](https://github.com/MeshCore-Beacon/beacon-server/actions/workflows/docker-publish.yml)
|
||||
|
||||
## What it does
|
||||
|
||||
- Subscribes to MeshCore MQTT brokers and decodes incoming LoRa packets using
|
||||
[meshcore-go](https://github.com/meshcore-go/meshcore-go)
|
||||
- Stores packets, observations, nodes, observers, and channel messages in
|
||||
PostgreSQL
|
||||
- Stores packets, observations, nodes, observers, traces, routes and channel
|
||||
messages in PostgreSQL (more backends to come)
|
||||
- Deduplicates observations across multiple brokers (same packet heard by two
|
||||
brokers is one observation per observer)
|
||||
- Decrypts group text messages for known channel keys
|
||||
@@ -40,40 +43,12 @@ For deployment instructions including the frontend app, see the deployment docs.
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
beacon-server/
|
||||
├── cmd/beacon/ entry point
|
||||
├── db/ store implementations and sqlc generated code
|
||||
│ ├── migrations/ SQL schema
|
||||
│ ├── queries/ sqlc query definitions
|
||||
│ └── sqlc/ generated Go DB code (do not edit)
|
||||
├── internal/
|
||||
│ ├── api/ REST API types, Reader interface, route handlers
|
||||
│ │ └── handlers/ HTTP route handlers
|
||||
│ ├── config/ config file loading and DB seeding
|
||||
│ ├── hub/ WebSocket fan-out broker
|
||||
│ ├── iatadb/ static IATA → country/continent map (generated)
|
||||
│ ├── ingest/ MQTT ingest pipeline
|
||||
│ ├── keystore/ channel key store
|
||||
│ ├── scopestore/ transport scope key store
|
||||
│ └── ws/ WebSocket handler and IP limiter
|
||||
├── config.yaml.example
|
||||
├── env.example
|
||||
├── docker-compose.yml
|
||||
└── sqlc.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.26+
|
||||
- Docker and Docker Compose
|
||||
- [sqlc](https://sqlc.dev) (only needed if modifying queries)
|
||||
|
||||
### 1. Clone and configure
|
||||
|
||||
@@ -102,6 +77,12 @@ start via `docker-entrypoint-initdb.d`.
|
||||
go run ./cmd/beacon
|
||||
```
|
||||
|
||||
Or pull and run the Docker image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/meshcore-beacon/beacon-server:latest
|
||||
```
|
||||
|
||||
Beacon will:
|
||||
|
||||
- Load `.env` and `config.yaml`
|
||||
@@ -207,6 +188,14 @@ not auto-created.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
API authentication is not yet implemented. Beacon is intended for trusted
|
||||
internal network or reverse-proxy deployments. Do not expose it directly to the
|
||||
public internet without an authentication layer in front of it.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket API
|
||||
|
||||
Connect to `ws://host:8080/ws`.
|
||||
@@ -235,6 +224,7 @@ unsubscribing.
|
||||
"scope": {
|
||||
"iatas": ["YOW", "YYZ"],
|
||||
"regionIds": ["1"],
|
||||
"regionSlugs": ["western-canada"],
|
||||
"payloadTypes": [4, 5],
|
||||
"channelHashes": ["11"],
|
||||
"events": ["packetObservation", "channelMessage"]
|
||||
@@ -243,8 +233,8 @@ unsubscribing.
|
||||
```
|
||||
|
||||
All scope fields are optional. Omitted means no filter on that dimension (match
|
||||
everything). Empty array means match nothing on that dimension. `regionIds` are
|
||||
expanded to their member IATAs server-side.
|
||||
everything). Empty array means match nothing on that dimension. `regionIds` and
|
||||
`regionSlugs` are both expanded to their member IATAs server-side.
|
||||
|
||||
**Unsubscribe** — remove a specific subscription by ID.
|
||||
|
||||
@@ -304,13 +294,15 @@ configurable via `websocket.max_connections_per_ip` in `config.yaml`.
|
||||
|
||||
Base path: `/api/v1`
|
||||
|
||||
All list endpoints support `afterId` for cursor-based pagination:
|
||||
All list endpoints support cursor-based pagination via `cursor` and `limit`
|
||||
query params. See the Swagger UI at `http://localhost:8080/swagger/index.html`
|
||||
for full parameter documentation.
|
||||
|
||||
```
|
||||
GET /api/v1/packets?iata=YOW&afterId=12345&limit=100
|
||||
```
|
||||
### Authentication
|
||||
|
||||
### Implemented
|
||||
Not yet implemented — see the Authentication section above.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
@@ -324,11 +316,12 @@ GET /api/v1/packets?iata=YOW&afterId=12345&limit=100
|
||||
| `GET` | `/messages/backfill` | Backfill messages after a given message ID |
|
||||
| `GET` | `/nodes` | List nodes |
|
||||
| `GET` | `/nodes/{nodeId}` | Get node detail |
|
||||
| `GET` | `/nodes/{nodeId}/neighbors` | List neighboring nodes observed in the mesh |
|
||||
| `GET` | `/nodes/{nodeId}/observations` | List observations for a node |
|
||||
| `GET` | `/observers` | List observers (optional: `?iata=<code>&type=<str>&broker=<name>&status=online\|offline`) |
|
||||
| `GET` | `/observers/{observerId}` | Get observer detail including broker last-seen timestamps |
|
||||
| `GET` | `/observers/{observerId}/adverts` | Adverts heard by observer |
|
||||
| `GET` | `/observers/{observerId}/telemetry` | Observer telemetry history |
|
||||
| `GET` | `/observers/{observerId}/telemetry` | Observer telemetry history (optional: `?range=24h&interval=1h\|6h\|24h`) |
|
||||
| `GET` | `/packets` | List packets with filters |
|
||||
| `GET` | `/packets/backfill` | Backfill packets after a given observation ID |
|
||||
| `GET` | `/packets/{packetHash}` | Get packet with all observations |
|
||||
@@ -336,6 +329,7 @@ GET /api/v1/packets?iata=YOW&afterId=12345&limit=100
|
||||
| `GET` | `/regions/{id}` | Get a single region with IATA list |
|
||||
| `GET` | `/routes` | List known routes (all hops high confidence) |
|
||||
| `GET` | `/routes/search` | Search routes by source and destination hash |
|
||||
| `GET` | `/routes/cross` | Search for routes crossing IATA boundaries |
|
||||
| `GET` | `/scopes` | List transport scopes |
|
||||
| `GET` | `/scopes/{name}` | Get scope detail |
|
||||
| `GET` | `/stats/observations` | Hourly observation time series (last 7 days by default) |
|
||||
@@ -349,139 +343,20 @@ GET /api/v1/packets?iata=YOW&afterId=12345&limit=100
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Modifying DB queries
|
||||
|
||||
Edit `db/queries/queries.sql`, then regenerate:
|
||||
|
||||
```bash
|
||||
sqlc generate
|
||||
```
|
||||
|
||||
### API documentation (Swagger)
|
||||
|
||||
Beacon uses [swaggo/swag](https://github.com/swaggo/swag) to generate OpenAPI
|
||||
documentation from annotations in the handler comments.
|
||||
|
||||
Start the server and open `http://localhost:8080/swagger/index.html`.
|
||||
|
||||
After adding or modifying any handler, regenerate the docs and commit the
|
||||
updated `docs/` directory alongside your handler changes:
|
||||
|
||||
```bash
|
||||
swag init -g cmd/beacon/main.go -o docs --parseDependecy
|
||||
```
|
||||
|
||||
Install swag:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
```
|
||||
|
||||
Each handler closure should have a godoc-style annotation block immediately
|
||||
above the `r.Get()`/`r.Post()` call:
|
||||
|
||||
```go
|
||||
// listThings godoc
|
||||
//
|
||||
// @Summary Short description shown in the UI
|
||||
// @Tags TagName
|
||||
// @Produce json
|
||||
// @Param paramName query string false "Description"
|
||||
// @Param id path string true "Resource ID"
|
||||
// @Success 200 {object} api.MyResponseType
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Router /things [get]
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
```
|
||||
|
||||
For paginated responses use the generic page wrapper:
|
||||
|
||||
```go
|
||||
// @Success 200 {object} api.Page[api.MyType]
|
||||
```
|
||||
|
||||
### Updating the IATA database
|
||||
|
||||
Beacon includes a static IATA → country/continent mapping compiled into the
|
||||
binary, generated from the [OurAirports](https://ourairports.com/data/) public
|
||||
dataset.
|
||||
|
||||
To refresh it with the latest airport data:
|
||||
|
||||
```bash
|
||||
rm internal/iatadb/gen/airports.csv
|
||||
go generate ./internal/iatadb/
|
||||
```
|
||||
|
||||
This fetches a fresh `airports.csv` from OurAirports, saves it locally, and
|
||||
regenerates `internal/iatadb/db.go`. Commit both files.
|
||||
|
||||
To use a local CSV instead (e.g. in a restricted network environment):
|
||||
|
||||
```bash
|
||||
AIRPORTS_CSV=/path/to/airports.csv go run ./internal/iatadb/gen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Road Map
|
||||
|
||||
### Done
|
||||
|
||||
- [x] MQTT ingest pipeline (two brokers, cross-broker dedup)
|
||||
- [x] Packet decode via meshcore-go
|
||||
- [x] Observer upsert and status processing
|
||||
- [x] Node upsert from advert payloads
|
||||
- [x] Channel message storage with key-based decryption
|
||||
- [x] Firmware capability detection scaffolding
|
||||
- [x] Hub-based WebSocket fan-out with subscription filtering
|
||||
- [x] WebSocket server (hello, subscribe, unsubscribe, ping/pong, lagged,
|
||||
events)
|
||||
- [x] WebSocket regionId expansion via region_iatas DB lookup
|
||||
- [x] WebSocket per-IP connection limits
|
||||
- [x] Config file loading (regions, IATA overrides, channel keys)
|
||||
- [x] Observer radio settings on observations
|
||||
- [x] DB seeding on startup
|
||||
- [x] Observer telemetry storage with configurable resolution and retention
|
||||
- [x] Packet retention cleanup goroutine
|
||||
- [x] Hashtag channel PSK derivation (SHA256("#tag")[:16])
|
||||
- [x] Channel hash collision handling via key fingerprint
|
||||
- [x] REST API: IATAs, Regions
|
||||
- [x] REST API: Channels (list + detail + messages) with IATA filter
|
||||
- [x] REST API: Messages (cross-channel) with IATA filter
|
||||
- [x] REST API: Observers (heard adverts, telemetry, list + detail with broker
|
||||
last-seen)
|
||||
- [x] REST API: Brokers (list with connection status)
|
||||
- [x] REST API: Pagination
|
||||
- [x] REST API: Nodes (list + detail + observations)
|
||||
- [x] REST API: Packets (list + detail)
|
||||
- [x] REST API: Stats
|
||||
- [x] Materialized view refresh (mv_hourly_iata_stats, mv_top_nodes_by_iata)
|
||||
- [x] Swagger/OpenAPI documentation via swaggo/swag
|
||||
- [x] Path resolution (node short ID lookup)
|
||||
- [x] Parse payloads (that we can decrypt) into DB and return with packet
|
||||
details
|
||||
- [x] Propagation time calculation
|
||||
- [x] Trace route resolution via path hashes (resolvedRoute on packet detail)
|
||||
- [x] Trace packets: trace tag storage, list and detail endpoints with resolved
|
||||
routes
|
||||
- [x] Known routes: fully resolved paths stored at ingest, list and search
|
||||
endpoints
|
||||
|
||||
### Future
|
||||
|
||||
- [ ] Redis caching for stats endpoints
|
||||
- [ ] Caddy reverse proxy config for production
|
||||
|
||||
- [ ] Admin authentication middleware
|
||||
- [ ] Server management via API (currently config-file only)
|
||||
- [ ] Observer owner tracking (schema exists, API excluded by design)
|
||||
- [ ] Server management via API (currently config-file only)
|
||||
- [ ] Log levels, debug and info
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the people who have helped build
|
||||
Beacon.
|
||||
|
||||
Beacon stands on the shoulders of giants. See [SHOULDERS.md](SHOULDERS.md) for
|
||||
the full list of open source projects that make this possible.
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do not report security vulnerabilities through public GitHub issues.
|
||||
|
||||
Instead, contact the maintainers directly via the MeshCore Canada Discord
|
||||
server: [MeshCore Canada Discord](https://discord.gg/Gz3KvJx2hf) — reach out to
|
||||
**dedskelly** directly. Include as much detail as possible: the nature of the
|
||||
issue, steps to reproduce, and any potential impact.
|
||||
|
||||
We will acknowledge receipt within 48 hours and aim to provide a fix or
|
||||
mitigation within 14 days depending on severity.
|
||||
|
||||
Once a fix is released we will publish a security advisory on the repository.
|
||||
|
||||
## Scope
|
||||
|
||||
Beacon is intended for deployment on trusted internal networks behind a reverse
|
||||
proxy. There is currently no authentication layer. Please bear this in mind when
|
||||
assessing the severity of any findings.
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
-- Copyright 2026 Beacon Contributors
|
||||
-- SPDX-License-Identifier: agpl
|
||||
|
||||
-- ============================================================
|
||||
-- Beacon schema migration
|
||||
-- ============================================================
|
||||
@@ -347,12 +350,30 @@ CREATE TABLE known_routes (
|
||||
hop_count INT NOT NULL,
|
||||
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
observation_count BIGINT NOt NULL DEFAULT 1,
|
||||
UNIQUE (node_ids, iata)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_known_routes_iata ON known_routes(iata);
|
||||
CREATE INDEX idx_known_routes_hop_count ON known_routes(iata, hop_count);
|
||||
|
||||
-- ============================================================
|
||||
-- NEIGHBORS
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE node_neighbors (
|
||||
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
neighbor_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE,
|
||||
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
observation_count BIGINT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (node_id, neighbor_id, iata)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_node_neighbors_node ON node_neighbors(node_id, iata);
|
||||
CREATE INDEX idx_node_neighbors_neighbor ON node_neighbors(neighbor_id, iata);
|
||||
|
||||
-- ============================================================
|
||||
-- MATERIALIZED VIEWS
|
||||
-- ============================================================
|
||||
|
||||
+58
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
@@ -50,6 +53,14 @@ func (s *Store) UpsertNodeShortID(ctx context.Context, nodeID uuid.UUID, iata st
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) UpsertNodeNeighbor(ctx context.Context, nodeID, neighborID uuid.UUID, iata string) error {
|
||||
return s.q.UpsertNodeNeighbor(ctx, sqlc.UpsertNodeNeighborParams{
|
||||
NodeID: nodeID,
|
||||
NeighborID: neighborID,
|
||||
Iata: iata,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) SetNodeCapability(ctx context.Context, nodeID uuid.UUID, paths, traces bool) error {
|
||||
var errs []error
|
||||
if paths {
|
||||
@@ -155,6 +166,12 @@ func (s *Store) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error
|
||||
LastSeen: row.LastSeen.Time.UnixMilli(),
|
||||
Metadata: row.Metadata,
|
||||
}
|
||||
neighbors, err := s.GetNodeNeighbors(ctx, nodeID)
|
||||
if err != nil {
|
||||
log.Printf("store: GetNodeNeighbors failed for %s: %v", nodeID, err)
|
||||
neighbors = []api.NodeNeighbor{}
|
||||
}
|
||||
node.Neighbors = neighbors
|
||||
if len(row.Iatas) > 0 {
|
||||
if err := json.Unmarshal(row.Iatas, &node.IATAs); err != nil {
|
||||
log.Printf("store: failed to unmarshal node iatas: %v", err)
|
||||
@@ -171,3 +188,44 @@ func (s *Store) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) {
|
||||
rows, err := s.q.GetNodesByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[uuid.UUID]*api.ResolvedNode, len(rows))
|
||||
for _, r := range rows {
|
||||
result[r.ID] = &api.ResolvedNode{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
PublicKey: hex.EncodeToString(r.PublicKey),
|
||||
Latitude: r.Latitude,
|
||||
Longitude: r.Longitude,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) {
|
||||
rows, err := s.q.GetNodeNeighbors(ctx, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]api.NodeNeighbor, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, api.NodeNeighbor{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
NodeType: r.NodeType,
|
||||
NodeTypeName: api.NodeTypeName(r.NodeType),
|
||||
Latitude: r.Latitude,
|
||||
Longitude: r.Longitude,
|
||||
IATA: r.Iata,
|
||||
ObservationCount: r.ObservationCount,
|
||||
FirstSeen: r.FirstSeen.Time.UnixMilli(),
|
||||
LastSeen: r.LastSeen.Time.UnixMilli(),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
+41
-2
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
@@ -166,8 +169,6 @@ func (s *Store) InsertObserverTelemetry(ctx context.Context, observerID uuid.UUI
|
||||
}
|
||||
|
||||
func (s *Store) GetObserverTelemetry(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error) {
|
||||
// TODO: implement server-side bucketing by interval when needed.
|
||||
// Currently returns all points in the range at stored resolution.
|
||||
rows, err := s.q.GetObserverTelemetry(ctx, sqlc.GetObserverTelemetryParams{
|
||||
ObserverID: observerID,
|
||||
Column2: pgtype.Timestamptz{Time: since, Valid: !since.IsZero()},
|
||||
@@ -193,6 +194,39 @@ func (s *Store) GetObserverTelemetry(ctx context.Context, observerID uuid.UUID,
|
||||
return &api.ObserverTelemetry{Points: points}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetObserverTelemetryBucketed(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) {
|
||||
var sinceTS, untilTS pgtype.Timestamptz
|
||||
if !since.IsZero() {
|
||||
sinceTS = pgtype.Timestamptz{Time: since, Valid: true}
|
||||
}
|
||||
if !until.IsZero() {
|
||||
untilTS = pgtype.Timestamptz{Time: until, Valid: true}
|
||||
}
|
||||
rows, err := s.q.GetObserverTelemetryBucketed(ctx, sqlc.GetObserverTelemetryBucketedParams{
|
||||
ObserverID: observerID,
|
||||
Column2: sinceTS,
|
||||
Column3: untilTS,
|
||||
Column4: bucketHours,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points := make([]api.ObserverTelemetryPoint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
points = append(points, api.ObserverTelemetryPoint{
|
||||
T: r.Bucket.Time.UnixMilli(),
|
||||
BatteryMV: &r.BatteryVoltageMv,
|
||||
AirtimeTxPct: &r.AirtimeTxPct,
|
||||
AirtimeRxPct: &r.AirtimeRxPct,
|
||||
NoiseFloorDB: &r.NoiseFloorDb,
|
||||
UptimeSeconds: &r.UptimeSeconds,
|
||||
QueueLength: &r.QueueLength,
|
||||
ReceiveErrors: &r.ReceiveErrors,
|
||||
})
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) {
|
||||
rows, err := s.q.ListObserverAdverts(ctx, sqlc.ListObserverAdvertsParams{
|
||||
ObserverID: observerID,
|
||||
@@ -286,6 +320,11 @@ func (s *Store) GetObserverScopes(ctx context.Context, observerID uuid.UUID) ([]
|
||||
return s.q.GetObserverScopes(ctx, observerID)
|
||||
}
|
||||
|
||||
func (s *Store) IsObserverByPubkey(ctx context.Context, pubkey []byte) bool {
|
||||
_, err := s.q.GetObserverByPubkey(ctx, pubkey)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOldTelemetry(ctx context.Context, cutoff time.Time) error {
|
||||
return s.q.DeleteOldTelemetry(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
+70
-3
@@ -1,3 +1,6 @@
|
||||
-- Copyright 2026 Beacon Contributors
|
||||
-- SPDX-License-Identifier: agpl
|
||||
|
||||
-- ============================================================
|
||||
-- IATA CODES
|
||||
-- ============================================================
|
||||
@@ -210,6 +213,24 @@ WHERE observer_id = $1
|
||||
AND ($4 = 0 OR id > $4)
|
||||
ORDER BY reported_at ASC;
|
||||
|
||||
-- name: GetObserverTelemetryBucketed :many
|
||||
SELECT
|
||||
(date_trunc('hour', reported_at) +
|
||||
(EXTRACT(HOUR FROM reported_at)::int / $4::int) * ($4::int * interval '1 hour'))::timestamptz AS bucket,
|
||||
AVG(battery_voltage_mv)::int AS battery_voltage_mv,
|
||||
AVG(airtime_tx_pct)::real AS airtime_tx_pct,
|
||||
AVG(airtime_rx_pct)::real AS airtime_rx_pct,
|
||||
AVG(noise_floor_db)::real AS noise_floor_db,
|
||||
MAX(uptime_seconds)::bigint AS uptime_seconds,
|
||||
AVG(queue_length)::int AS queue_length,
|
||||
AVG(receive_errors)::int AS receive_errors
|
||||
FROM observer_telemetry
|
||||
WHERE observer_id = $1
|
||||
AND ($2::timestamptz IS NULL OR reported_at >= $2)
|
||||
AND ($3::timestamptz IS NULL OR reported_at <= $3)
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC;
|
||||
|
||||
-- name: ListObserverAdverts :many
|
||||
-- Returns advert packets (payload_type=4) heard by a specific observer.
|
||||
-- Pass cursor=0 to start from the beginning, or the last seen id for pagination.
|
||||
@@ -473,6 +494,10 @@ FROM nodes n
|
||||
LEFT JOIN transport_scopes ts ON ts.id = n.default_scope_id
|
||||
WHERE n.id = $1;
|
||||
|
||||
-- name: GetNodesByIDs :many
|
||||
SELECT id, public_key, name, latitude, longitude
|
||||
FROM nodes
|
||||
WHERE id = ANY($1::uuid[]);
|
||||
|
||||
-- name: ListNodes :many
|
||||
SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.last_seen,
|
||||
@@ -832,10 +857,11 @@ LIMIT $6;
|
||||
INSERT INTO known_routes (node_ids, hash_prefix, iata, hop_count)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (node_ids, iata) DO UPDATE SET
|
||||
last_seen = NOW();
|
||||
last_seen = NOW(),
|
||||
observation_count = known_routes.observation_count + 1;
|
||||
|
||||
-- name: ListKnownRoutes :many
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE ($1 = '' OR iata = $1)
|
||||
AND ($2 = 0 OR hop_count = $2)
|
||||
@@ -846,7 +872,7 @@ LIMIT $4;
|
||||
-- name: SearchKnownRoutes :many
|
||||
-- Returns known routes containing a subsequence from source to destination hash prefix.
|
||||
-- Verifies source appears before destination in the route.
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE iata = $1
|
||||
AND array_position(hash_prefix, $2::bytea) IS NOT NULL
|
||||
@@ -854,6 +880,47 @@ WHERE iata = $1
|
||||
AND array_position(hash_prefix, $2::bytea) < array_position(hash_prefix, $3::bytea)
|
||||
ORDER BY hop_count ASC, last_seen DESC;
|
||||
|
||||
-- name: GetKnownRoutesByNode :many
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE iata = $1
|
||||
AND $2::uuid = ANY(node_ids)
|
||||
ORDER BY hop_count ASC, last_seen DESC;
|
||||
|
||||
-- ============================================================
|
||||
-- NEIGHBORS
|
||||
-- ============================================================
|
||||
|
||||
-- name: UpsertNodeNeighbor :exec
|
||||
-- Records or updates a neighbor relationship between two nodes observed in the same IATA.
|
||||
-- node_id is the advertising node, neighbor_id is the first-hop forwarder.
|
||||
INSERT INTO node_neighbors (node_id, neighbor_id, iata, observation_count)
|
||||
VALUES ($1, $2, $3, 1)
|
||||
ON CONFLICT (node_id, neighbor_id, iata) DO UPDATE SET
|
||||
last_seen = NOW(),
|
||||
observation_count = node_neighbors.observation_count + 1;
|
||||
|
||||
-- name: GetNodeNeighbors :many
|
||||
-- Returns the neighbors of a node with details, ordered by most recently seen.
|
||||
SELECT
|
||||
n.id, n.name, n.node_type, n.latitude, n.longitude,
|
||||
nn.iata, nn.observation_count, nn.first_seen, nn.last_seen
|
||||
FROM node_neighbors nn
|
||||
JOIN nodes n ON n.id = nn.neighbor_id
|
||||
WHERE nn.node_id = $1
|
||||
ORDER BY nn.last_seen DESC;
|
||||
|
||||
-- name: GetCrossIATANeighbors :many
|
||||
-- Returns neighbors of a node that are in a different IATA.
|
||||
SELECT
|
||||
n.id, n.name, n.node_type, n.latitude, n.longitude,
|
||||
nn.iata AS neighbor_iata, nn.observation_count, nn.last_seen
|
||||
FROM node_neighbors nn
|
||||
JOIN nodes n ON n.id = nn.neighbor_id
|
||||
WHERE nn.node_id = $1
|
||||
AND nn.iata != $2
|
||||
ORDER BY nn.last_seen DESC;
|
||||
|
||||
-- ============================================================
|
||||
-- HELPERS
|
||||
-- ============================================================
|
||||
|
||||
+209
-16
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
@@ -34,7 +37,12 @@ func (s *Store) ListKnownRoutes(ctx context.Context, iata string, hopCount int32
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toKnownRoutes(rows), nil
|
||||
ids := collectNodeIDs(rows)
|
||||
nodes, err := s.GetNodesByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toKnownRoutes(rows, nodes), nil
|
||||
}
|
||||
|
||||
func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) {
|
||||
@@ -54,9 +62,13 @@ func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := collectNodeIDs(rows)
|
||||
nodes, err := s.GetNodesByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]api.KnownRoute, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
// find positions and slice to the subsequence
|
||||
fromPos, toPos := -1, -1
|
||||
for i, h := range r.HashPrefix {
|
||||
if fromPos == -1 && hex.EncodeToString(h) == fromHash {
|
||||
@@ -74,31 +86,197 @@ func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash st
|
||||
hashPrefix := r.HashPrefix[fromPos : toPos+1]
|
||||
hops := make([]api.RouteHop, 0, len(nodeIDs))
|
||||
for i, nodeID := range nodeIDs {
|
||||
hop := api.RouteHop{NodeID: nodeID}
|
||||
hop := api.RouteHop{
|
||||
NodeID: nodeID,
|
||||
Node: nodes[nodeID],
|
||||
}
|
||||
if i < len(hashPrefix) {
|
||||
hop.HashBytes = hex.EncodeToString(hashPrefix[i])
|
||||
}
|
||||
hops = append(hops, hop)
|
||||
}
|
||||
items = append(items, api.KnownRoute{
|
||||
ID: r.ID,
|
||||
IATA: r.Iata,
|
||||
HopCount: int32(len(hops)),
|
||||
Hops: hops,
|
||||
FirstSeen: r.FirstSeen.Time.UnixMilli(),
|
||||
LastSeen: r.LastSeen.Time.UnixMilli(),
|
||||
ID: r.ID,
|
||||
IATA: r.Iata,
|
||||
HopCount: int32(len(hops)),
|
||||
Hops: hops,
|
||||
FirstSeen: r.FirstSeen.Time.UnixMilli(),
|
||||
LastSeen: r.LastSeen.Time.UnixMilli(),
|
||||
ObservationCount: r.ObservationCount,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func toKnownRoutes(rows []sqlc.KnownRoute) []api.KnownRoute {
|
||||
func (s *Store) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) {
|
||||
rows, err := s.q.GetKnownRoutesByNode(ctx, sqlc.GetKnownRoutesByNodeParams{
|
||||
Iata: iata,
|
||||
Column2: nodeID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := collectNodeIDs(rows)
|
||||
nodes, err := s.GetNodesByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toKnownRoutes(rows, nodes), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) {
|
||||
rows, err := s.q.GetCrossIATANeighbors(ctx, sqlc.GetCrossIATANeighborsParams{
|
||||
NodeID: nodeID,
|
||||
Iata: iata,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]api.NodeNeighbor, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, api.NodeNeighbor{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
NodeType: r.NodeType,
|
||||
NodeTypeName: api.NodeTypeName(r.NodeType),
|
||||
Latitude: r.Latitude,
|
||||
Longitude: r.Longitude,
|
||||
IATA: r.NeighborIata,
|
||||
ObservationCount: r.ObservationCount,
|
||||
LastSeen: r.LastSeen.Time.UnixMilli(),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) {
|
||||
// 1. resolve fromHash in fromIATA
|
||||
fromBytes, err := hex.DecodeString(fromHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromResolved, err := s.ResolvePathHashes(ctx, fromIATA, [][]byte{fromBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromEntries := fromResolved[fromHash]
|
||||
if len(fromEntries) != 1 {
|
||||
return nil, nil // not found or ambiguous
|
||||
}
|
||||
fromNodeID := fromEntries[0].NodeID
|
||||
|
||||
// 2. resolve toHash in toIATA
|
||||
toBytes, err := hex.DecodeString(toHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toResolved, err := s.ResolvePathHashes(ctx, toIATA, [][]byte{toBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toEntries := toResolved[toHash]
|
||||
if len(toEntries) != 1 {
|
||||
return nil, nil // not found or ambiguous
|
||||
}
|
||||
toNodeID := toEntries[0].NodeID
|
||||
|
||||
// 3. find routes in source IATA containing fromNode
|
||||
sourceRoutes, err := s.GetKnownRoutesByNode(ctx, fromIATA, fromNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. find routes in target IATA containing toNode
|
||||
targetRoutes, err := s.GetKnownRoutesByNode(ctx, toIATA, toNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(sourceRoutes) == 0 || len(targetRoutes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 5. find cross-IATA links — nodes at the boundary of source routes
|
||||
// that have neighbors in the target IATA at the start of target routes
|
||||
var results []api.CrossIATARoute
|
||||
|
||||
// build a set of node IDs that appear in target routes
|
||||
targetNodeSet := make(map[uuid.UUID][]api.RouteHop)
|
||||
for _, tr := range targetRoutes {
|
||||
for _, hop := range tr.Hops {
|
||||
if _, ok := targetNodeSet[hop.NodeID]; !ok {
|
||||
targetNodeSet[hop.NodeID] = tr.Hops
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// for each source route, check if any node has a cross-IATA neighbor in targetNodeSet
|
||||
for _, sr := range sourceRoutes {
|
||||
for i, hop := range sr.Hops {
|
||||
crossNeighbors, err := s.GetCrossIATANeighbors(ctx, hop.NodeID, fromIATA)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, neighbor := range crossNeighbors {
|
||||
if neighbor.IATA != toIATA {
|
||||
continue
|
||||
}
|
||||
if targetHops, ok := targetNodeSet[neighbor.ID]; ok {
|
||||
// found a cross-IATA link — build the route
|
||||
sourceSegment := sr.Hops[:i+1]
|
||||
targetSegment := extractFromNode(targetHops, neighbor.ID)
|
||||
|
||||
fromNode := api.ResolvedNode{
|
||||
ID: hop.NodeID,
|
||||
Latitude: fromEntries[0].Latitude,
|
||||
Longitude: fromEntries[0].Longitude,
|
||||
PublicKey: hex.EncodeToString(fromEntries[0].PublicKey),
|
||||
}
|
||||
toNode := api.ResolvedNode{
|
||||
ID: neighbor.ID,
|
||||
Name: neighbor.Name,
|
||||
Latitude: neighbor.Latitude,
|
||||
Longitude: neighbor.Longitude,
|
||||
}
|
||||
|
||||
results = append(results, api.CrossIATARoute{
|
||||
SourceSegment: sourceSegment,
|
||||
CrossHop: api.CrossIATAHop{
|
||||
FromNode: fromNode,
|
||||
ToNode: toNode,
|
||||
FromIATA: fromIATA,
|
||||
ToIATA: toIATA,
|
||||
LastSeen: neighbor.LastSeen,
|
||||
},
|
||||
TargetSegment: targetSegment,
|
||||
TotalHops: len(sourceSegment) + 1 + len(targetSegment),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// extractFromNode returns the portion of a route starting at the given node.
|
||||
func extractFromNode(hops []api.RouteHop, nodeID uuid.UUID) []api.RouteHop {
|
||||
for i, hop := range hops {
|
||||
if hop.NodeID == nodeID {
|
||||
return hops[i:]
|
||||
}
|
||||
}
|
||||
return hops
|
||||
}
|
||||
|
||||
func toKnownRoutes(rows []sqlc.KnownRoute, nodes map[uuid.UUID]*api.ResolvedNode) []api.KnownRoute {
|
||||
items := make([]api.KnownRoute, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
hops := make([]api.RouteHop, 0, len(r.NodeIds))
|
||||
for i, nodeID := range r.NodeIds {
|
||||
hop := api.RouteHop{
|
||||
NodeID: nodeID,
|
||||
Node: nodes[nodeID],
|
||||
}
|
||||
if i < len(r.HashPrefix) {
|
||||
hop.HashBytes = hex.EncodeToString(r.HashPrefix[i])
|
||||
@@ -106,13 +284,28 @@ func toKnownRoutes(rows []sqlc.KnownRoute) []api.KnownRoute {
|
||||
hops = append(hops, hop)
|
||||
}
|
||||
items = append(items, api.KnownRoute{
|
||||
ID: r.ID,
|
||||
IATA: r.Iata,
|
||||
HopCount: r.HopCount,
|
||||
Hops: hops,
|
||||
FirstSeen: r.FirstSeen.Time.UnixMilli(),
|
||||
LastSeen: r.LastSeen.Time.UnixMilli(),
|
||||
ID: r.ID,
|
||||
IATA: r.Iata,
|
||||
HopCount: r.HopCount,
|
||||
Hops: hops,
|
||||
FirstSeen: r.FirstSeen.Time.UnixMilli(),
|
||||
LastSeen: r.LastSeen.Time.UnixMilli(),
|
||||
ObservationCount: r.ObservationCount,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func collectNodeIDs(rows []sqlc.KnownRoute) []uuid.UUID {
|
||||
seen := make(map[uuid.UUID]struct{})
|
||||
var ids []uuid.UUID
|
||||
for _, r := range rows {
|
||||
for _, id := range r.NodeIds {
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestExtractFromNode_Found(t *testing.T) {
|
||||
a, b, c := uuid.New(), uuid.New(), uuid.New()
|
||||
hops := []api.RouteHop{
|
||||
{NodeID: a},
|
||||
{NodeID: b},
|
||||
{NodeID: c},
|
||||
}
|
||||
result := extractFromNode(hops, b)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 hops, got %d", len(result))
|
||||
}
|
||||
if result[0].NodeID != b {
|
||||
t.Errorf("expected first hop to be b, got %s", result[0].NodeID)
|
||||
}
|
||||
if result[1].NodeID != c {
|
||||
t.Errorf("expected second hop to be c, got %s", result[1].NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromNode_FirstNode(t *testing.T) {
|
||||
a, b := uuid.New(), uuid.New()
|
||||
hops := []api.RouteHop{{NodeID: a}, {NodeID: b}}
|
||||
result := extractFromNode(hops, a)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 hops, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromNode_NotFound(t *testing.T) {
|
||||
a, b := uuid.New(), uuid.New()
|
||||
hops := []api.RouteHop{{NodeID: a}}
|
||||
result := extractFromNode(hops, b)
|
||||
// not found returns full slice
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected full slice returned, got %d hops", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromNode_Empty(t *testing.T) {
|
||||
result := extractFromNode(nil, uuid.New())
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected empty result for nil hops")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
+17
-7
@@ -50,13 +50,14 @@ type IataCode struct {
|
||||
}
|
||||
|
||||
type KnownRoute struct {
|
||||
ID int64 `json:"id"`
|
||||
NodeIds []uuid.UUID `json:"node_ids"`
|
||||
HashPrefix [][]byte `json:"hash_prefix"`
|
||||
Iata string `json:"iata"`
|
||||
HopCount int32 `json:"hop_count"`
|
||||
FirstSeen pgtype.Timestamptz `json:"first_seen"`
|
||||
LastSeen pgtype.Timestamptz `json:"last_seen"`
|
||||
ID int64 `json:"id"`
|
||||
NodeIds []uuid.UUID `json:"node_ids"`
|
||||
HashPrefix [][]byte `json:"hash_prefix"`
|
||||
Iata string `json:"iata"`
|
||||
HopCount int32 `json:"hop_count"`
|
||||
FirstSeen pgtype.Timestamptz `json:"first_seen"`
|
||||
LastSeen pgtype.Timestamptz `json:"last_seen"`
|
||||
ObservationCount int64 `json:"observation_count"`
|
||||
}
|
||||
|
||||
type MvHourlyIataStat struct {
|
||||
@@ -112,6 +113,15 @@ type NodeIata struct {
|
||||
ObservationCount *int64 `json:"observation_count"`
|
||||
}
|
||||
|
||||
type NodeNeighbor struct {
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
NeighborID uuid.UUID `json:"neighbor_id"`
|
||||
Iata string `json:"iata"`
|
||||
FirstSeen pgtype.Timestamptz `json:"first_seen"`
|
||||
LastSeen pgtype.Timestamptz `json:"last_seen"`
|
||||
ObservationCount int64 `json:"observation_count"`
|
||||
}
|
||||
|
||||
type NodeShortID struct {
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
Iata string `json:"iata"`
|
||||
|
||||
+294
-3
@@ -149,6 +149,63 @@ func (q *Queries) GetChannelsByHash(ctx context.Context, arg GetChannelsByHashPa
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getCrossIATANeighbors = `-- name: GetCrossIATANeighbors :many
|
||||
SELECT
|
||||
n.id, n.name, n.node_type, n.latitude, n.longitude,
|
||||
nn.iata AS neighbor_iata, nn.observation_count, nn.last_seen
|
||||
FROM node_neighbors nn
|
||||
JOIN nodes n ON n.id = nn.neighbor_id
|
||||
WHERE nn.node_id = $1
|
||||
AND nn.iata != $2
|
||||
ORDER BY nn.last_seen DESC
|
||||
`
|
||||
|
||||
type GetCrossIATANeighborsParams struct {
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
Iata string `json:"iata"`
|
||||
}
|
||||
|
||||
type GetCrossIATANeighborsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
NodeType int16 `json:"node_type"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
NeighborIata string `json:"neighbor_iata"`
|
||||
ObservationCount int64 `json:"observation_count"`
|
||||
LastSeen pgtype.Timestamptz `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Returns neighbors of a node that are in a different IATA.
|
||||
func (q *Queries) GetCrossIATANeighbors(ctx context.Context, arg GetCrossIATANeighborsParams) ([]GetCrossIATANeighborsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getCrossIATANeighbors, arg.NodeID, arg.Iata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetCrossIATANeighborsRow{}
|
||||
for rows.Next() {
|
||||
var i GetCrossIATANeighborsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NodeType,
|
||||
&i.Latitude,
|
||||
&i.Longitude,
|
||||
&i.NeighborIata,
|
||||
&i.ObservationCount,
|
||||
&i.LastSeen,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getHourlyStats = `-- name: GetHourlyStats :many
|
||||
SELECT iata, hour, observation_count, unique_packets, active_observers
|
||||
FROM mv_hourly_iata_stats
|
||||
@@ -205,6 +262,48 @@ func (q *Queries) GetIATA(ctx context.Context, iata string) (IataCode, error) {
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getKnownRoutesByNode = `-- name: GetKnownRoutesByNode :many
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE iata = $1
|
||||
AND $2::uuid = ANY(node_ids)
|
||||
ORDER BY hop_count ASC, last_seen DESC
|
||||
`
|
||||
|
||||
type GetKnownRoutesByNodeParams struct {
|
||||
Iata string `json:"iata"`
|
||||
Column2 uuid.UUID `json:"column_2"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetKnownRoutesByNode(ctx context.Context, arg GetKnownRoutesByNodeParams) ([]KnownRoute, error) {
|
||||
rows, err := q.db.Query(ctx, getKnownRoutesByNode, arg.Iata, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []KnownRoute{}
|
||||
for rows.Next() {
|
||||
var i KnownRoute
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.NodeIds,
|
||||
&i.HashPrefix,
|
||||
&i.Iata,
|
||||
&i.HopCount,
|
||||
&i.FirstSeen,
|
||||
&i.LastSeen,
|
||||
&i.ObservationCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getNodeByID = `-- name: GetNodeByID :one
|
||||
SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.location_source, n.last_advert_at, n.supports_multibyte_paths, n.supports_multibyte_traces, n.default_scope_id, n.min_firmware_version, n.first_seen, n.last_seen, n.radio_freq_mhz, n.radio_sf, n.radio_bw_khz, n.metadata, ts.name AS default_scope_name,
|
||||
EXISTS (SELECT 1 FROM observers o WHERE o.public_key = n.public_key) AS is_observer,
|
||||
@@ -361,6 +460,99 @@ func (q *Queries) GetNodeIATAs(ctx context.Context, nodeID uuid.UUID) ([]string,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getNodeNeighbors = `-- name: GetNodeNeighbors :many
|
||||
SELECT
|
||||
n.id, n.name, n.node_type, n.latitude, n.longitude,
|
||||
nn.iata, nn.observation_count, nn.first_seen, nn.last_seen
|
||||
FROM node_neighbors nn
|
||||
JOIN nodes n ON n.id = nn.neighbor_id
|
||||
WHERE nn.node_id = $1
|
||||
ORDER BY nn.last_seen DESC
|
||||
`
|
||||
|
||||
type GetNodeNeighborsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
NodeType int16 `json:"node_type"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
Iata string `json:"iata"`
|
||||
ObservationCount int64 `json:"observation_count"`
|
||||
FirstSeen pgtype.Timestamptz `json:"first_seen"`
|
||||
LastSeen pgtype.Timestamptz `json:"last_seen"`
|
||||
}
|
||||
|
||||
// Returns the neighbors of a node with details, ordered by most recently seen.
|
||||
func (q *Queries) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]GetNodeNeighborsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getNodeNeighbors, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetNodeNeighborsRow{}
|
||||
for rows.Next() {
|
||||
var i GetNodeNeighborsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NodeType,
|
||||
&i.Latitude,
|
||||
&i.Longitude,
|
||||
&i.Iata,
|
||||
&i.ObservationCount,
|
||||
&i.FirstSeen,
|
||||
&i.LastSeen,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getNodesByIDs = `-- name: GetNodesByIDs :many
|
||||
SELECT id, public_key, name, latitude, longitude
|
||||
FROM nodes
|
||||
WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
type GetNodesByIDsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PublicKey []byte `json:"public_key"`
|
||||
Name *string `json:"name"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetNodesByIDs(ctx context.Context, dollar_1 []uuid.UUID) ([]GetNodesByIDsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getNodesByIDs, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetNodesByIDsRow{}
|
||||
for rows.Next() {
|
||||
var i GetNodesByIDsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.PublicKey,
|
||||
&i.Name,
|
||||
&i.Latitude,
|
||||
&i.Longitude,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getObserverBrokers = `-- name: GetObserverBrokers :many
|
||||
SELECT broker_name, last_seen, last_packet_at
|
||||
FROM observer_brokers
|
||||
@@ -591,6 +783,77 @@ func (q *Queries) GetObserverTelemetry(ctx context.Context, arg GetObserverTelem
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getObserverTelemetryBucketed = `-- name: GetObserverTelemetryBucketed :many
|
||||
SELECT
|
||||
(date_trunc('hour', reported_at) +
|
||||
(EXTRACT(HOUR FROM reported_at)::int / $4::int) * ($4::int * interval '1 hour'))::timestamptz AS bucket,
|
||||
AVG(battery_voltage_mv)::int AS battery_voltage_mv,
|
||||
AVG(airtime_tx_pct)::real AS airtime_tx_pct,
|
||||
AVG(airtime_rx_pct)::real AS airtime_rx_pct,
|
||||
AVG(noise_floor_db)::real AS noise_floor_db,
|
||||
MAX(uptime_seconds)::bigint AS uptime_seconds,
|
||||
AVG(queue_length)::int AS queue_length,
|
||||
AVG(receive_errors)::int AS receive_errors
|
||||
FROM observer_telemetry
|
||||
WHERE observer_id = $1
|
||||
AND ($2::timestamptz IS NULL OR reported_at >= $2)
|
||||
AND ($3::timestamptz IS NULL OR reported_at <= $3)
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC
|
||||
`
|
||||
|
||||
type GetObserverTelemetryBucketedParams struct {
|
||||
ObserverID uuid.UUID `json:"observer_id"`
|
||||
Column2 pgtype.Timestamptz `json:"column_2"`
|
||||
Column3 pgtype.Timestamptz `json:"column_3"`
|
||||
Column4 int32 `json:"column_4"`
|
||||
}
|
||||
|
||||
type GetObserverTelemetryBucketedRow struct {
|
||||
Bucket pgtype.Timestamptz `json:"bucket"`
|
||||
BatteryVoltageMv int32 `json:"battery_voltage_mv"`
|
||||
AirtimeTxPct float32 `json:"airtime_tx_pct"`
|
||||
AirtimeRxPct float32 `json:"airtime_rx_pct"`
|
||||
NoiseFloorDb float32 `json:"noise_floor_db"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
QueueLength int32 `json:"queue_length"`
|
||||
ReceiveErrors int32 `json:"receive_errors"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetObserverTelemetryBucketed(ctx context.Context, arg GetObserverTelemetryBucketedParams) ([]GetObserverTelemetryBucketedRow, error) {
|
||||
rows, err := q.db.Query(ctx, getObserverTelemetryBucketed,
|
||||
arg.ObserverID,
|
||||
arg.Column2,
|
||||
arg.Column3,
|
||||
arg.Column4,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetObserverTelemetryBucketedRow{}
|
||||
for rows.Next() {
|
||||
var i GetObserverTelemetryBucketedRow
|
||||
if err := rows.Scan(
|
||||
&i.Bucket,
|
||||
&i.BatteryVoltageMv,
|
||||
&i.AirtimeTxPct,
|
||||
&i.AirtimeRxPct,
|
||||
&i.NoiseFloorDb,
|
||||
&i.UptimeSeconds,
|
||||
&i.QueueLength,
|
||||
&i.ReceiveErrors,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getPacketByHash = `-- name: GetPacketByHash :one
|
||||
SELECT p.packet_hash, p.payload_type, p.payload_version, p.route_type, p.transport_codes_present, p.region_code, p.sub_region_code, p.scope_id, p.origin_pubkey, p.raw_payload, p.raw_header, p.parsed_payload, p.decrypted, p.channel_hash, p.trace_tag, p.first_heard_at, p.last_heard_at, ts.name AS scope_name,
|
||||
cm.sender_name AS cm_sender_name,
|
||||
@@ -1714,7 +1977,7 @@ func (q *Queries) ListIATAs(ctx context.Context) ([]IataCode, error) {
|
||||
}
|
||||
|
||||
const listKnownRoutes = `-- name: ListKnownRoutes :many
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE ($1 = '' OR iata = $1)
|
||||
AND ($2 = 0 OR hop_count = $2)
|
||||
@@ -1752,6 +2015,7 @@ func (q *Queries) ListKnownRoutes(ctx context.Context, arg ListKnownRoutesParams
|
||||
&i.HopCount,
|
||||
&i.FirstSeen,
|
||||
&i.LastSeen,
|
||||
&i.ObservationCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2701,7 +2965,7 @@ func (q *Queries) ResolvePathHashes(ctx context.Context, arg ResolvePathHashesPa
|
||||
}
|
||||
|
||||
const searchKnownRoutes = `-- name: SearchKnownRoutes :many
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen
|
||||
SELECT id, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count
|
||||
FROM known_routes
|
||||
WHERE iata = $1
|
||||
AND array_position(hash_prefix, $2::bytea) IS NOT NULL
|
||||
@@ -2735,6 +2999,7 @@ func (q *Queries) SearchKnownRoutes(ctx context.Context, arg SearchKnownRoutesPa
|
||||
&i.HopCount,
|
||||
&i.FirstSeen,
|
||||
&i.LastSeen,
|
||||
&i.ObservationCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2975,7 +3240,8 @@ const upsertKnownRoute = `-- name: UpsertKnownRoute :exec
|
||||
INSERT INTO known_routes (node_ids, hash_prefix, iata, hop_count)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (node_ids, iata) DO UPDATE SET
|
||||
last_seen = NOW()
|
||||
last_seen = NOW(),
|
||||
observation_count = known_routes.observation_count + 1
|
||||
`
|
||||
|
||||
type UpsertKnownRouteParams struct {
|
||||
@@ -3090,6 +3356,31 @@ func (q *Queries) UpsertNodeIATA(ctx context.Context, arg UpsertNodeIATAParams)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertNodeNeighbor = `-- name: UpsertNodeNeighbor :exec
|
||||
|
||||
INSERT INTO node_neighbors (node_id, neighbor_id, iata, observation_count)
|
||||
VALUES ($1, $2, $3, 1)
|
||||
ON CONFLICT (node_id, neighbor_id, iata) DO UPDATE SET
|
||||
last_seen = NOW(),
|
||||
observation_count = node_neighbors.observation_count + 1
|
||||
`
|
||||
|
||||
type UpsertNodeNeighborParams struct {
|
||||
NodeID uuid.UUID `json:"node_id"`
|
||||
NeighborID uuid.UUID `json:"neighbor_id"`
|
||||
Iata string `json:"iata"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NEIGHBORS
|
||||
// ============================================================
|
||||
// Records or updates a neighbor relationship between two nodes observed in the same IATA.
|
||||
// node_id is the advertising node, neighbor_id is the first-hop forwarder.
|
||||
func (q *Queries) UpsertNodeNeighbor(ctx context.Context, arg UpsertNodeNeighborParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertNodeNeighbor, arg.NodeID, arg.NeighborID, arg.Iata)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertNodeShortID = `-- name: UpsertNodeShortID :exec
|
||||
INSERT INTO node_short_ids (node_id, iata, prefix_4)
|
||||
VALUES ($1, $2, $3)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
+11
-5
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package db implements the ingest.DB interface using sqlc-generated queries
|
||||
// over a pgx/v5 connection pool. Each method is a thin mapping layer between
|
||||
// the ingest param structs and the sqlc-generated param structs.
|
||||
@@ -26,8 +29,6 @@ func New(pool *pgxpool.Pool) *Store {
|
||||
return &Store{q: sqlc.New(pool)}
|
||||
}
|
||||
|
||||
// ResolvePathHashes returns a map of hex-encoded path hash → matching node entries for
|
||||
// the given IATA. Hash size is inferred from the length of the first element in hashes.
|
||||
func (s *Store) ResolvePathHashes(ctx context.Context, iata string, hashes [][]byte) (map[string][]api.ResolvedPathEntry, error) {
|
||||
if len(hashes) == 0 {
|
||||
return nil, nil
|
||||
@@ -99,8 +100,9 @@ func toChannelMessage(id int64, packetHashHex string, channelHash []byte, sender
|
||||
// Returns nil if the payload cannot be parsed or contains no path hashes.
|
||||
func (s *Store) resolveTraceRoute(ctx context.Context, parsedPayload []byte, iatas []string) []api.ResolvedHop {
|
||||
var tracePayload struct {
|
||||
PathHashes []string `json:"pathHashes"`
|
||||
Flags byte `json:"flags"`
|
||||
PathHashes []string `json:"pathHashes"`
|
||||
Flags byte `json:"flags"`
|
||||
SNRValues []float32 `json:"snrValues"`
|
||||
}
|
||||
if err := json.Unmarshal(parsedPayload, &tracePayload); err != nil || len(tracePayload.PathHashes) == 0 {
|
||||
return nil
|
||||
@@ -145,11 +147,15 @@ func (s *Store) resolveTraceRoute(ctx context.Context, parsedPayload []byte, iat
|
||||
}
|
||||
}
|
||||
route := make([]api.ResolvedHop, 0, len(hashes))
|
||||
for _, hr := range merged {
|
||||
for i, hr := range merged {
|
||||
hop := api.ResolvedHop{
|
||||
Confidence: hr.confidence,
|
||||
Nodes: make([]api.ResolvedNode, 0, len(hr.entries)),
|
||||
}
|
||||
if i < len(tracePayload.SNRValues) {
|
||||
snr := tracePayload.SNRValues[i]
|
||||
hop.SNR = &snr
|
||||
}
|
||||
for _, e := range hr.entries {
|
||||
hop.Nodes = append(hop.Nodes, api.ResolvedNode{
|
||||
ID: e.NodeID,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func TestNullableUUID_Zero(t *testing.T) {
|
||||
if nullableUUID(uuid.UUID{}) != nil {
|
||||
t.Error("expected nil for zero UUID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullableUUID_NonZero(t *testing.T) {
|
||||
id := uuid.New()
|
||||
result := nullableUUID(id)
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil for non-zero UUID")
|
||||
}
|
||||
if *result != id {
|
||||
t.Errorf("expected %s, got %s", id, *result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTristate_Nil(t *testing.T) {
|
||||
if tristate(nil) != "any" {
|
||||
t.Error("expected \"any\" for nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTristate_True(t *testing.T) {
|
||||
b := true
|
||||
if tristate(&b) != "true" {
|
||||
t.Error("expected \"true\" for true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTristate_False(t *testing.T) {
|
||||
b := false
|
||||
if tristate(&b) != "false" {
|
||||
t.Error("expected \"false\" for false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToChannelMessage(t *testing.T) {
|
||||
senderName := "Alice"
|
||||
content := "hello"
|
||||
channelHash := []byte{0xab}
|
||||
sentAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
|
||||
|
||||
msg := toChannelMessage(42, "deadbeef", channelHash, &senderName, &content, sentAt, 7)
|
||||
|
||||
if msg.ID != 42 {
|
||||
t.Errorf("expected ID 42, got %d", msg.ID)
|
||||
}
|
||||
if msg.PacketHash != "deadbeef" {
|
||||
t.Errorf("expected PacketHash deadbeef, got %s", msg.PacketHash)
|
||||
}
|
||||
if msg.ChannelHash != "ab" {
|
||||
t.Errorf("expected ChannelHash ab, got %s", msg.ChannelHash)
|
||||
}
|
||||
if msg.SenderName != "Alice" {
|
||||
t.Errorf("expected SenderName Alice, got %s", msg.SenderName)
|
||||
}
|
||||
if msg.Content != "hello" {
|
||||
t.Errorf("expected Content hello, got %s", msg.Content)
|
||||
}
|
||||
if msg.SentAt != 1700000000000 {
|
||||
t.Errorf("expected SentAt 1700000000000, got %d", msg.SentAt)
|
||||
}
|
||||
if msg.ObservationCount != 7 {
|
||||
t.Errorf("expected ObservationCount 7, got %d", msg.ObservationCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToChannelMessage_NilFields(t *testing.T) {
|
||||
sentAt := pgtype.Timestamptz{Time: time.UnixMilli(0), Valid: true}
|
||||
msg := toChannelMessage(1, "abc", []byte{0x01}, nil, nil, sentAt, 0)
|
||||
if msg.SenderName != "" {
|
||||
t.Errorf("expected empty SenderName, got %s", msg.SenderName)
|
||||
}
|
||||
if msg.Content != "" {
|
||||
t.Errorf("expected empty Content, got %s", msg.Content)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
||||
+377
-1
@@ -596,6 +596,49 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nodes/{nodeId}/neighbors": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Nodes"
|
||||
],
|
||||
"summary": "List neighbors for a node",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Node UUID",
|
||||
"name": "nodeId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nodes/{nodeId}/observations": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -870,7 +913,7 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bucketing interval, echoed back in the response; not yet applied server-side",
|
||||
"description": "Bucketing interval: 1h (default), 6h, or 24h",
|
||||
"name": "interval",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -1207,6 +1250,181 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Routes"
|
||||
],
|
||||
"summary": "List known routes",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by IATA code",
|
||||
"name": "iata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Filter by exact hop count",
|
||||
"name": "hopCount",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Route ID of last item for pagination",
|
||||
"name": "cursor",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Max results (default 50)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes/cross": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Routes"
|
||||
],
|
||||
"summary": "Search for routes that cross IATA boundaries",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex)",
|
||||
"name": "fromHash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source IATA code",
|
||||
"name": "fromIata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination node hash prefix (hex)",
|
||||
"name": "toHash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination IATA code",
|
||||
"name": "toIata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATARoute"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes/search": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Routes"
|
||||
],
|
||||
"summary": "Search known routes by source and destination hash",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IATA code to search within",
|
||||
"name": "iata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex)",
|
||||
"name": "from",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination node hash prefix (hex)",
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/scopes": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -1830,6 +2048,67 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fromIata": {
|
||||
"type": "string"
|
||||
},
|
||||
"fromNode": {
|
||||
"description": "last node in source IATA",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lastSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"toIata": {
|
||||
"type": "string"
|
||||
},
|
||||
"toNode": {
|
||||
"description": "first node in target IATA",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATARoute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crossHop": {
|
||||
"description": "the boundary hop",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceSegment": {
|
||||
"description": "route segment in source IATA",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop"
|
||||
}
|
||||
},
|
||||
"targetSegment": {
|
||||
"description": "route segment in target IATA",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop"
|
||||
}
|
||||
},
|
||||
"totalHops": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.IATA": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1847,6 +2126,37 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"hopCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hops": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop"
|
||||
}
|
||||
},
|
||||
"iata": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lastSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"observationCount": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.Node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1902,6 +2212,12 @@ const docTemplate = `{
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"neighbors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor"
|
||||
}
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "1=companion, 2=repeater, 3=room_server, 4=sensor",
|
||||
"type": "integer"
|
||||
@@ -1943,6 +2259,43 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"iata": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"lat": {
|
||||
"type": "number"
|
||||
},
|
||||
"lng": {
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodeType": {
|
||||
"type": "integer"
|
||||
},
|
||||
"nodeTypeName": {
|
||||
"type": "string"
|
||||
},
|
||||
"observationCount": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.NodeSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2684,6 +3037,9 @@ const docTemplate = `{
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode"
|
||||
}
|
||||
},
|
||||
"snr": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2708,6 +3064,26 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hashBytes": {
|
||||
"description": "hex-encoded hash prefix",
|
||||
"type": "string"
|
||||
},
|
||||
"node": {
|
||||
"description": "populated when node details are available",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nodeId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+377
-1
@@ -594,6 +594,49 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nodes/{nodeId}/neighbors": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Nodes"
|
||||
],
|
||||
"summary": "List neighbors for a node",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Node UUID",
|
||||
"name": "nodeId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nodes/{nodeId}/observations": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -868,7 +911,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bucketing interval, echoed back in the response; not yet applied server-side",
|
||||
"description": "Bucketing interval: 1h (default), 6h, or 24h",
|
||||
"name": "interval",
|
||||
"in": "query"
|
||||
}
|
||||
@@ -1205,6 +1248,181 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Routes"
|
||||
],
|
||||
"summary": "List known routes",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by IATA code",
|
||||
"name": "iata",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Filter by exact hop count",
|
||||
"name": "hopCount",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Route ID of last item for pagination",
|
||||
"name": "cursor",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Max results (default 50)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes/cross": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Routes"
|
||||
],
|
||||
"summary": "Search for routes that cross IATA boundaries",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex)",
|
||||
"name": "fromHash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source IATA code",
|
||||
"name": "fromIata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination node hash prefix (hex)",
|
||||
"name": "toHash",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination IATA code",
|
||||
"name": "toIata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATARoute"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes/search": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Routes"
|
||||
],
|
||||
"summary": "Search known routes by source and destination hash",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IATA code to search within",
|
||||
"name": "iata",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Source node hash prefix (hex)",
|
||||
"name": "from",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Destination node hash prefix (hex)",
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/scopes": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -1828,6 +2046,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fromIata": {
|
||||
"type": "string"
|
||||
},
|
||||
"fromNode": {
|
||||
"description": "last node in source IATA",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"lastSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"toIata": {
|
||||
"type": "string"
|
||||
},
|
||||
"toNode": {
|
||||
"description": "first node in target IATA",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATARoute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crossHop": {
|
||||
"description": "the boundary hop",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceSegment": {
|
||||
"description": "route segment in source IATA",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop"
|
||||
}
|
||||
},
|
||||
"targetSegment": {
|
||||
"description": "route segment in target IATA",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop"
|
||||
}
|
||||
},
|
||||
"totalHops": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.IATA": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1845,6 +2124,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"hopCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hops": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop"
|
||||
}
|
||||
},
|
||||
"iata": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lastSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"observationCount": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.Node": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1900,6 +2210,12 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"neighbors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor"
|
||||
}
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "1=companion, 2=repeater, 3=room_server, 4=sensor",
|
||||
"type": "integer"
|
||||
@@ -1941,6 +2257,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"iata": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastSeen": {
|
||||
"description": "epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"lat": {
|
||||
"type": "number"
|
||||
},
|
||||
"lng": {
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodeType": {
|
||||
"type": "integer"
|
||||
},
|
||||
"nodeTypeName": {
|
||||
"type": "string"
|
||||
},
|
||||
"observationCount": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.NodeSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2682,6 +3035,9 @@
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode"
|
||||
}
|
||||
},
|
||||
"snr": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2706,6 +3062,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hashBytes": {
|
||||
"description": "hex-encoded hash prefix",
|
||||
"type": "string"
|
||||
},
|
||||
"node": {
|
||||
"description": "populated when node details are available",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nodeId": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+246
-2
@@ -101,6 +101,43 @@ definitions:
|
||||
description: display name from config or nil
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop:
|
||||
properties:
|
||||
fromIata:
|
||||
type: string
|
||||
fromNode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode'
|
||||
description: last node in source IATA
|
||||
lastSeen:
|
||||
description: epoch ms
|
||||
type: integer
|
||||
toIata:
|
||||
type: string
|
||||
toNode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode'
|
||||
description: first node in target IATA
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATARoute:
|
||||
properties:
|
||||
crossHop:
|
||||
allOf:
|
||||
- $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop'
|
||||
description: the boundary hop
|
||||
sourceSegment:
|
||||
description: route segment in source IATA
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop'
|
||||
type: array
|
||||
targetSegment:
|
||||
description: route segment in target IATA
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop'
|
||||
type: array
|
||||
totalHops:
|
||||
type: integer
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.IATA:
|
||||
properties:
|
||||
displayName:
|
||||
@@ -112,6 +149,27 @@ definitions:
|
||||
lon:
|
||||
type: number
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute:
|
||||
properties:
|
||||
firstSeen:
|
||||
description: epoch ms
|
||||
type: integer
|
||||
hopCount:
|
||||
type: integer
|
||||
hops:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop'
|
||||
type: array
|
||||
iata:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
lastSeen:
|
||||
description: epoch ms
|
||||
type: integer
|
||||
observationCount:
|
||||
type: integer
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.Node:
|
||||
properties:
|
||||
defaultScope:
|
||||
@@ -152,6 +210,10 @@ definitions:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
neighbors:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor'
|
||||
type: array
|
||||
nodeType:
|
||||
description: 1=companion, 2=repeater, 3=room_server, 4=sensor
|
||||
type: integer
|
||||
@@ -181,6 +243,31 @@ definitions:
|
||||
description: epoch ms
|
||||
type: integer
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor:
|
||||
properties:
|
||||
firstSeen:
|
||||
description: epoch ms
|
||||
type: integer
|
||||
iata:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
lastSeen:
|
||||
description: epoch ms
|
||||
type: integer
|
||||
lat:
|
||||
type: number
|
||||
lng:
|
||||
type: number
|
||||
name:
|
||||
type: string
|
||||
nodeType:
|
||||
type: integer
|
||||
nodeTypeName:
|
||||
type: string
|
||||
observationCount:
|
||||
type: integer
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.NodeSummary:
|
||||
properties:
|
||||
defaultScope:
|
||||
@@ -693,6 +780,8 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode'
|
||||
type: array
|
||||
snr:
|
||||
type: number
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode:
|
||||
properties:
|
||||
@@ -708,6 +797,18 @@ definitions:
|
||||
description: hex-encoded prefix used for resolution
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop:
|
||||
properties:
|
||||
hashBytes:
|
||||
description: hex-encoded hash prefix
|
||||
type: string
|
||||
node:
|
||||
allOf:
|
||||
- $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ResolvedNode'
|
||||
description: populated when node details are available
|
||||
nodeId:
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail:
|
||||
properties:
|
||||
iataCount:
|
||||
@@ -1240,6 +1341,34 @@ paths:
|
||||
summary: Get node detail
|
||||
tags:
|
||||
- Nodes
|
||||
/nodes/{nodeId}/neighbors:
|
||||
get:
|
||||
parameters:
|
||||
- description: Node UUID
|
||||
in: path
|
||||
name: nodeId
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.NodeNeighbor'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
summary: List neighbors for a node
|
||||
tags:
|
||||
- Nodes
|
||||
/nodes/{nodeId}/observations:
|
||||
get:
|
||||
parameters:
|
||||
@@ -1415,8 +1544,7 @@ paths:
|
||||
in: query
|
||||
name: afterId
|
||||
type: integer
|
||||
- description: Bucketing interval, echoed back in the response; not yet applied
|
||||
server-side
|
||||
- description: 'Bucketing interval: 1h (default), 6h, or 24h'
|
||||
in: query
|
||||
name: interval
|
||||
type: string
|
||||
@@ -1644,6 +1772,122 @@ paths:
|
||||
summary: Get a single region
|
||||
tags:
|
||||
- Regions
|
||||
/routes:
|
||||
get:
|
||||
parameters:
|
||||
- description: Filter by IATA code
|
||||
in: query
|
||||
name: iata
|
||||
type: string
|
||||
- description: Filter by exact hop count
|
||||
in: query
|
||||
name: hopCount
|
||||
type: integer
|
||||
- description: Route ID of last item for pagination
|
||||
in: query
|
||||
name: cursor
|
||||
type: integer
|
||||
- description: Max results (default 50)
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute'
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
summary: List known routes
|
||||
tags:
|
||||
- Routes
|
||||
/routes/cross:
|
||||
get:
|
||||
parameters:
|
||||
- description: Source node hash prefix (hex)
|
||||
in: query
|
||||
name: fromHash
|
||||
required: true
|
||||
type: string
|
||||
- description: Source IATA code
|
||||
in: query
|
||||
name: fromIata
|
||||
required: true
|
||||
type: string
|
||||
- description: Destination node hash prefix (hex)
|
||||
in: query
|
||||
name: toHash
|
||||
required: true
|
||||
type: string
|
||||
- description: Destination IATA code
|
||||
in: query
|
||||
name: toIata
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATARoute'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
summary: Search for routes that cross IATA boundaries
|
||||
tags:
|
||||
- Routes
|
||||
/routes/search:
|
||||
get:
|
||||
parameters:
|
||||
- description: IATA code to search within
|
||||
in: query
|
||||
name: iata
|
||||
required: true
|
||||
type: string
|
||||
- description: Source node hash prefix (hex)
|
||||
in: query
|
||||
name: from
|
||||
required: true
|
||||
type: string
|
||||
- description: Destination node hash prefix (hex)
|
||||
in: query
|
||||
name: to
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
summary: Search known routes by source and destination hash
|
||||
tags:
|
||||
- Routes
|
||||
/scopes:
|
||||
get:
|
||||
parameters:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
// ChannelMessage represents a single decrypted channel message.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestListChannels_InvalidLimit(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/channels", listChannels(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels?limit=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannels_InvalidCursor(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/channels", listChannels(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels?cursor=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannels_InvalidHash(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/channels", listChannels(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels?hash=nothex!!", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannels_HashNotSingleByte(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/channels", listChannels(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels?hash=aabb", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetChannel_InvalidID(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/channels/{channelID}", getChannel(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannelMessages_InvalidChannelID(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/channels/{channelID}/messages", listChannelMessages(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/notanint/messages", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannelMessages_InvalidLimit(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/channels/{channelID}/messages", listChannelMessages(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/1/messages?limit=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannelMessages_InvalidSince(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/channels/{channelID}/messages", listChannelMessages(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/1/messages?since=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannelMessages_InvalidCursor(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/channels/{channelID}/messages", listChannelMessages(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/1/messages?cursor=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -21,6 +24,7 @@ func NodesRouter(reader api.Reader) http.Handler {
|
||||
r.Route("/{nodeId}", func(r chi.Router) {
|
||||
r.Get("/", getNode(reader))
|
||||
r.Get("/observations", listNodeObservations(reader))
|
||||
r.Get("/neighbors", listNodeNeighbors(reader))
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -196,3 +200,29 @@ func listNodeObservations(reader api.Reader) http.HandlerFunc {
|
||||
respond(w, http.StatusOK, observations)
|
||||
}
|
||||
}
|
||||
|
||||
// listNodeNeighbors godoc
|
||||
//
|
||||
// @Summary List neighbors for a node
|
||||
// @Tags Nodes
|
||||
// @Produce json
|
||||
// @Param nodeId path string true "Node UUID"
|
||||
// @Success 200 {object} []api.NodeNeighbor
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Router /nodes/{nodeId}/neighbors [get]
|
||||
func listNodeNeighbors(reader api.Reader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
nodeID, err := uuid.Parse(chi.URLParam(r, "nodeId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid node ID")
|
||||
return
|
||||
}
|
||||
neighbors, err := reader.GetNodeNeighbors(r.Context(), nodeID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, neighbors)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestGetNode_InvalidUUID(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes/{nodeId}", getNode(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes/not-a-uuid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodeObservations_InvalidUUID(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes/{nodeId}/observations", listNodeObservations(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes/bad/observations", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodeObservations_InvalidCursor(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes/{nodeId}/observations", listNodeObservations(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes/00000000-0000-0000-0000-000000000001/observations?cursor=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodeObservations_InvalidLimit(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes/{nodeId}/observations", listNodeObservations(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes/00000000-0000-0000-0000-000000000001/observations?limit=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodes_InvalidType(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes", listNodes(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes?type=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodes_InvalidLimit(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes", listNodes(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes?limit=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodes_InvalidCursor(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes", listNodes(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes?cursor=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodes_InvalidPubkey(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes", listNodes(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes?pubkey=nothex!!", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodes_InvalidSupportsMultibytePaths(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes", listNodes(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes?supportsMultibytePaths=notabool", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNodes_InvalidSupportsMultibyteTraces(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/nodes", listNodes(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/nodes?supportsMultibyteTraces=notabool", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -171,7 +174,7 @@ func listObserverAdverts(reader api.Reader) http.HandlerFunc {
|
||||
// @Param observerId path string true "Observer UUID"
|
||||
// @Param range query string false "Duration window e.g. 24h, 48h, 168h (default 24h)"
|
||||
// @Param afterId query int false "Return points after this telemetry ID for WS reconnection backfill"
|
||||
// @Param interval query string false "Bucketing interval, echoed back in the response; not yet applied server-side"
|
||||
// @Param interval query string false "Bucketing interval: 1h (default), 6h, or 24h"
|
||||
// @Success 200 {object} api.ObserverTelemetry
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
@@ -201,15 +204,39 @@ func getObserverTelemetry(reader api.Reader) http.HandlerFunc {
|
||||
}
|
||||
afterID = id
|
||||
}
|
||||
intervalParam := r.URL.Query().Get("interval")
|
||||
if intervalParam == "" {
|
||||
intervalParam = "1h"
|
||||
}
|
||||
var bucketHours int32
|
||||
switch intervalParam {
|
||||
case "1h":
|
||||
bucketHours = 0 // use raw query
|
||||
case "6h":
|
||||
bucketHours = 6
|
||||
case "24h":
|
||||
bucketHours = 24
|
||||
default:
|
||||
respondError(w, http.StatusBadRequest, "invalid interval, use 1h, 6h or 24h")
|
||||
return
|
||||
}
|
||||
since := time.Now().Add(-duration)
|
||||
until := time.Time{} // no upper bound
|
||||
telemetry, err := reader.GetObserverTelemetry(r.Context(), observerID, since, until, afterID)
|
||||
var telemetry *api.ObserverTelemetry
|
||||
if bucketHours == 0 {
|
||||
telemetry, err = reader.GetObserverTelemetry(r.Context(), observerID, since, until, afterID)
|
||||
} else {
|
||||
points, err := reader.GetObserverTelemetryBucketed(r.Context(), observerID, since, until, bucketHours)
|
||||
if err == nil {
|
||||
telemetry = &api.ObserverTelemetry{Points: points}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
telemetry.Range = rangeParam
|
||||
telemetry.Interval = r.URL.Query().Get("interval") // echoed back, not used server-side yet
|
||||
telemetry.Interval = intervalParam
|
||||
respond(w, http.StatusOK, telemetry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestGetObserverTelemetry_InvalidUUID(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/observers/not-a-uuid/telemetry", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObserverTelemetry_InvalidRange(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/observers/00000000-0000-0000-0000-000000000001/telemetry?range=banana", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObserverTelemetry_InvalidAfterID(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/observers/00000000-0000-0000-0000-000000000001/telemetry?afterId=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObserverTelemetry_InvalidInterval(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/telemetry", getObserverTelemetry(stubReader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/observers/00000000-0000-0000-0000-000000000001/telemetry?interval=2h", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestGetPacket_InvalidHex(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets/{packetHash}", getPacket(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets/nothex!!", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPackets_InvalidPayloadType(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets", listPackets(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets?payloadType=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPackets_InvalidRouteType(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets", listPackets(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets?routeType=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPackets_InvalidSince(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets", listPackets(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets?since=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPackets_InvalidUntil(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets", listPackets(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets?until=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPackets_InvalidCursor(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets", listPackets(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets?cursor=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPackets_InvalidLimit(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets", listPackets(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets?limit=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPacketsBackfill_MissingAfterID(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets/backfill", listPacketsBackfill(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets/backfill", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPacketsBackfill_InvalidAfterID(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets/backfill", listPacketsBackfill(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets/backfill?afterObservationId=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPacketsBackfill_InvalidLimit(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/packets/backfill", listPacketsBackfill(stubReader{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/packets/backfill?afterObservationId=1&limit=notanint", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseIATAs_Single(t *testing.T) {
|
||||
r := &http.Request{URL: &url.URL{RawQuery: "iata=yvr"}}
|
||||
result := parseIATAs(r)
|
||||
if len(result) != 1 || result[0] != "YVR" {
|
||||
t.Errorf("expected [YVR], got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIATAs_Multiple(t *testing.T) {
|
||||
r := &http.Request{URL: &url.URL{RawQuery: "iatas=yvr,yyj,yyc"}}
|
||||
result := parseIATAs(r)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 IATAs, got %d", len(result))
|
||||
}
|
||||
if result[0] != "YVR" || result[1] != "YYJ" || result[2] != "YYC" {
|
||||
t.Errorf("unexpected IATAs: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIATAs_MultiplePreferredOverSingle(t *testing.T) {
|
||||
// iatas param takes precedence over iata
|
||||
r := &http.Request{URL: &url.URL{RawQuery: "iatas=yvr,yyj&iata=yyc"}}
|
||||
result := parseIATAs(r)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 IATAs, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIATAs_Whitespace(t *testing.T) {
|
||||
r := &http.Request{URL: &url.URL{RawQuery: "iatas=yvr%2C+yyj"}}
|
||||
result := parseIATAs(r)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 IATAs, got %d", len(result))
|
||||
}
|
||||
if result[1] != "YYJ" {
|
||||
t.Errorf("expected YYJ after trimming whitespace, got %s", result[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIATAs_Empty(t *testing.T) {
|
||||
r := &http.Request{URL: &url.URL{RawQuery: ""}}
|
||||
result := parseIATAs(r)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil for empty query, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIATAs_Uppercase(t *testing.T) {
|
||||
r := &http.Request{URL: &url.URL{RawQuery: "iata=YVR"}}
|
||||
result := parseIATAs(r)
|
||||
if len(result) != 1 || result[0] != "YVR" {
|
||||
t.Errorf("expected [YVR], got %v", result)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package handlers provides HTTP route handlers for the Beacon REST API.
|
||||
package handlers
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRespond_StatusAndContentType(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
respond(w, http.StatusOK, map[string]string{"hello": "world"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("expected application/json, got %s", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespond_EncodesJSON(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
respond(w, http.StatusOK, map[string]string{"key": "value"})
|
||||
var result map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if result["key"] != "value" {
|
||||
t.Errorf("expected value, got %s", result["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondError_StatusCode(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
respondError(w, http.StatusBadRequest, "invalid input")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondError_Shape(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
respondError(w, http.StatusBadRequest, "invalid input")
|
||||
var result map[string]APIError
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode error response: %v", err)
|
||||
}
|
||||
e, ok := result["error"]
|
||||
if !ok {
|
||||
t.Fatal("expected 'error' key in response")
|
||||
}
|
||||
if e.Code != "bad_request" {
|
||||
t.Errorf("expected code bad_request, got %s", e.Code)
|
||||
}
|
||||
if e.Message != "invalid input" {
|
||||
t.Errorf("expected message 'invalid input', got %s", e.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondError_CodeDerivation(t *testing.T) {
|
||||
tests := []struct {
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{http.StatusBadRequest, "bad_request"},
|
||||
{http.StatusNotFound, "not_found"},
|
||||
{http.StatusInternalServerError, "internal_server_error"},
|
||||
{http.StatusUnauthorized, "unauthorized"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
w := httptest.NewRecorder()
|
||||
respondError(w, tt.status, "msg")
|
||||
var result map[string]APIError
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("status %d: failed to decode: %v", tt.status, err)
|
||||
}
|
||||
if result["error"].Code != tt.want {
|
||||
t.Errorf("status %d: expected code %s, got %s", tt.status, tt.want, result["error"].Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -16,6 +19,7 @@ import (
|
||||
func RoutesRouter(reader api.Reader) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", listKnownRoutes(reader))
|
||||
r.Get("/cross", searchCrossIATARoutes(reader))
|
||||
r.Get("/search", searchKnownRoutes(reader))
|
||||
return r
|
||||
}
|
||||
@@ -91,3 +95,38 @@ func searchKnownRoutes(reader api.Reader) http.HandlerFunc {
|
||||
respond(w, http.StatusOK, routes)
|
||||
}
|
||||
}
|
||||
|
||||
// searchCrossIATARoutes godoc
|
||||
//
|
||||
// @Summary Search for routes that cross IATA boundaries
|
||||
// @Tags Routes
|
||||
// @Produce json
|
||||
// @Param fromHash query string true "Source node hash prefix (hex)"
|
||||
// @Param fromIata query string true "Source IATA code"
|
||||
// @Param toHash query string true "Destination node hash prefix (hex)"
|
||||
// @Param toIata query string true "Destination IATA code"
|
||||
// @Success 200 {object} []api.CrossIATARoute
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Router /routes/cross [get]
|
||||
func searchCrossIATARoutes(reader api.Reader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
fromHash := r.URL.Query().Get("fromHash")
|
||||
fromIATA := r.URL.Query().Get("fromIata")
|
||||
toHash := r.URL.Query().Get("toHash")
|
||||
toIATA := r.URL.Query().Get("toIata")
|
||||
if fromHash == "" || fromIATA == "" || toHash == "" || toIATA == "" {
|
||||
respondError(w, http.StatusBadRequest, "fromHash, fromIata, toHash and toIata are required")
|
||||
return
|
||||
}
|
||||
routes, err := reader.SearchCrossIATARoutes(r.Context(), fromHash, fromIATA, toHash, toIATA)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
if routes == nil {
|
||||
routes = []api.CrossIATARoute{}
|
||||
}
|
||||
respond(w, http.StatusOK, routes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestSearchKnownRoutes_MissingParams(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/routes/search", searchKnownRoutes(stubReader{}))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{"missing all", ""},
|
||||
{"missing from and to", "?iata=YVR"},
|
||||
{"missing to", "?iata=YVR&from=aa"},
|
||||
{"missing iata", "?from=aa&to=bb"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest(http.MethodGet, "/routes/search"+tt.query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: expected 400, got %d", tt.name, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCrossIATARoutes_MissingParams(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/routes/cross", searchCrossIATARoutes(stubReader{}))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
{"missing all", ""},
|
||||
{"missing toHash and toIata", "?fromHash=aa&fromIata=YVR"},
|
||||
{"missing fromIata", "?fromHash=aa&toHash=bb&toIata=YYJ"},
|
||||
{"missing fromHash", "?fromIata=YVR&toHash=bb&toIata=YYJ"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest(http.MethodGet, "/routes/cross"+tt.query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: expected 400, got %d", tt.name, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// stubReader satisfies api.Reader with zero-value returns.
|
||||
// Use it for handler tests that exercise validation paths where
|
||||
// the reader is never actually called.
|
||||
type stubReader struct{}
|
||||
|
||||
func (stubReader) ListIATAs(ctx context.Context) ([]api.IATA, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetIATA(ctx context.Context, iata string) (*api.IATA, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListRegions(ctx context.Context) ([]api.RegionSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetRegion(ctx context.Context, regionID int32) (*api.Region, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetRegionBySlug(ctx context.Context, slug string) (*api.Region, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListChannels(ctx context.Context, limit int32, hash []byte, iata string, cursor int64) (api.Page[api.ChannelSummary], error) {
|
||||
return api.Page[api.ChannelSummary]{}, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetChannel(ctx context.Context, channelID int32) (*api.Channel, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListChannelMessages(ctx context.Context, channelID *int32, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) {
|
||||
return api.Page[api.ChannelMessage]{}, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32, iatas []string, scope string, cursor int64) (api.Page[api.ChannelMessage], error) {
|
||||
return api.Page[api.ChannelMessage]{}, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListMessagesAfterID(ctx context.Context, afterID int64, iatas []string, scope string, limit int32) ([]api.ChannelMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListObservers(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, limit int32) (api.Page[api.ObserverSummary], error) {
|
||||
return api.Page[api.ObserverSummary]{}, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetObserver(ctx context.Context, observerID uuid.UUID) (*api.Observer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetObserverTelemetry(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetObserverTelemetryBucketed(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetObserverScopes(ctx context.Context, observerID uuid.UUID) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error) {
|
||||
return api.Page[api.AdvertObservation]{}, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, name, scope string, cursor int64, limit int32) (api.Page[api.NodeSummary], error) {
|
||||
return api.Page[api.NodeSummary]{}, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListNodeObservations(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (api.Page[api.PacketObservationSummary], error) {
|
||||
return api.Page[api.PacketObservationSummary]{}, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) {
|
||||
return api.Page[api.PacketSummary]{}, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListPacketsAfterID(ctx context.Context, afterObservationID int64, payloadType, routeType int16, iatas []string, scope string, limit int32) ([]api.PacketSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetPacket(ctx context.Context, packetHash []byte) (*api.Packet, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetRadioPresets(ctx context.Context, preset, iata string) ([]api.RadioPreset, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetStatsOverview(ctx context.Context, iata string) (*api.StatsOverview, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetStatsObservations(ctx context.Context, iata string, since time.Time) ([]api.ObservationPoint, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetStatsPayloadBreakdown(ctx context.Context, iata string, since time.Time) ([]api.PayloadBreakdownItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetStatsTopNodes(ctx context.Context, iata string, limit int32) ([]api.TopNode, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetStatsTopObservers(ctx context.Context, iata string, since time.Time, limit int32) ([]api.TopObserver, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetScopeStats(ctx context.Context) ([]api.ScopeStats, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetScopeNames(ctx context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetScopesByIATAs(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetScopeByName(ctx context.Context, name string) (*api.ScopeDetail, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListTraceTags(ctx context.Context, iatas []string, scope string, since, until time.Time, cursor time.Time, limit int32) ([]api.TraceTagSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetTraceByTag(ctx context.Context, tag string) (*api.TraceDetail, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, limit int32) ([]api.KnownRoute, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]api.KnownRoute, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]api.NodeNeighbor, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]api.KnownRoute, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]api.NodeNeighbor, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubReader) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*api.ResolvedNode, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
// IATA represents a known airport/location code used to group observers and packets.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
+26
-8
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -7,6 +10,20 @@ import (
|
||||
"github.com/meshcore-go/meshcore-go"
|
||||
)
|
||||
|
||||
// NodeNeighbor represents a neighboring node relationship observed in a given IATA.
|
||||
type NodeNeighbor struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
NodeType int16 `json:"nodeType"`
|
||||
NodeTypeName string `json:"nodeTypeName"`
|
||||
Latitude *float64 `json:"lat,omitempty"`
|
||||
Longitude *float64 `json:"lng,omitempty"`
|
||||
IATA string `json:"iata"`
|
||||
ObservationCount int64 `json:"observationCount"`
|
||||
FirstSeen int64 `json:"firstSeen"` // epoch ms
|
||||
LastSeen int64 `json:"lastSeen"` // epoch ms
|
||||
}
|
||||
|
||||
// NodeIATA represents a single IATA code and the last time the node was heard there.
|
||||
type NodeIATA struct {
|
||||
IATA string `json:"iata"`
|
||||
@@ -33,14 +50,15 @@ type NodeSummary struct {
|
||||
// location source, and timing metadata.
|
||||
type Node struct {
|
||||
NodeSummary
|
||||
LocationSource *string `json:"locationSource,omitempty"` // "advert" or "manual"
|
||||
LastAdvertAt *int64 `json:"lastAdvertAt,omitempty"` // epoch ms, nil if no advert received
|
||||
SupportsMultibytePaths bool `json:"supportsMultibytePaths"` // firmware >= 1.14.0; detected via path hash size
|
||||
SupportsMultibyteTraces bool `json:"supportsMultibyteTraces"` // firmware >= 1.11.0; detected via trace hash size
|
||||
MinFirmwareVersion *string `json:"minFirmwareVersion,omitempty"` // derived from capability flags
|
||||
FirstSeen int64 `json:"firstSeen"` // epoch ms
|
||||
LastSeen int64 `json:"lastSeen"` // epoch ms
|
||||
Metadata any `json:"metadata,omitempty"` // raw JSONB metadata
|
||||
LocationSource *string `json:"locationSource,omitempty"` // "advert" or "manual"
|
||||
LastAdvertAt *int64 `json:"lastAdvertAt,omitempty"` // epoch ms, nil if no advert received
|
||||
SupportsMultibytePaths bool `json:"supportsMultibytePaths"` // firmware >= 1.14.0; detected via path hash size
|
||||
SupportsMultibyteTraces bool `json:"supportsMultibyteTraces"` // firmware >= 1.11.0; detected via trace hash size
|
||||
MinFirmwareVersion *string `json:"minFirmwareVersion,omitempty"` // derived from capability flags
|
||||
FirstSeen int64 `json:"firstSeen"` // epoch ms
|
||||
LastSeen int64 `json:"lastSeen"` // epoch ms
|
||||
Metadata any `json:"metadata,omitempty"` // raw JSONB metadata
|
||||
Neighbors []NodeNeighbor `json:"neighbors"`
|
||||
}
|
||||
|
||||
// NodeTypeName returns a human-readable name for a node type integer.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/meshcore-go/meshcore-go"
|
||||
)
|
||||
|
||||
func TestNodeTypeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int16
|
||||
want string
|
||||
}{
|
||||
{int16(meshcore.AdvertTypeChat), "companion"},
|
||||
{int16(meshcore.AdvertTypeRepeater), "repeater"},
|
||||
{int16(meshcore.AdvertTypeRoom), "room_server"},
|
||||
{int16(meshcore.AdvertTypeSensor), "sensor"},
|
||||
{99, "unknown"},
|
||||
{0, "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := api.NodeTypeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NodeTypeName(%d) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeTypeFromString(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int16
|
||||
}{
|
||||
{"companion", int16(meshcore.AdvertTypeChat)},
|
||||
{"chat", int16(meshcore.AdvertTypeChat)},
|
||||
{"repeater", int16(meshcore.AdvertTypeRepeater)},
|
||||
{"room_server", int16(meshcore.AdvertTypeRoom)},
|
||||
{"roomserver", int16(meshcore.AdvertTypeRoom)},
|
||||
{"room-server", int16(meshcore.AdvertTypeRoom)},
|
||||
{"room", int16(meshcore.AdvertTypeRoom)},
|
||||
{"sensor", int16(meshcore.AdvertTypeSensor)},
|
||||
{"REPEATER", int16(meshcore.AdvertTypeRepeater)}, // case insensitive
|
||||
{"", 0},
|
||||
{"unknown", 0},
|
||||
{"garbage", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := api.NodeTypeFromString(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NodeTypeFromString(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -68,7 +71,8 @@ type PacketRadio struct {
|
||||
// Confidence is "high" (exactly one match), "ambiguous" (multiple matches), or "none" (no match).
|
||||
type ResolvedHop struct {
|
||||
Confidence string `json:"confidence"` // "high", "ambiguous", or "none"
|
||||
Nodes []ResolvedNode `json:"nodes"` // empty for "none", one for "high", multiple for "ambiguous"
|
||||
SNR *float32 `json:"snr,omitempty"`
|
||||
Nodes []ResolvedNode `json:"nodes"` // empty for "none", one for "high", multiple for "ambiguous"
|
||||
}
|
||||
|
||||
// ResolvedNode is a node reference within a resolved path hop.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/meshcore-go/meshcore-go"
|
||||
)
|
||||
|
||||
func TestPayloadTypeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int16
|
||||
want string
|
||||
}{
|
||||
{0x00, "request"},
|
||||
{0x01, "response"},
|
||||
{0x02, "text_message"},
|
||||
{0x03, "acknowledgement"},
|
||||
{0x04, "advert"},
|
||||
{0x05, "group_text"},
|
||||
{0x06, "group_data"},
|
||||
{0x07, "anonymous_request"},
|
||||
{0x08, "path"},
|
||||
{0x09, "trace"},
|
||||
{0x0A, "multipart"},
|
||||
{0x0B, "control"},
|
||||
{0x0C, "reserved"},
|
||||
{0x0D, "reserved"},
|
||||
{0x0E, "reserved"},
|
||||
{0x0F, "raw_custom"},
|
||||
{0xFF, "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := api.PayloadTypeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("PayloadTypeName(%#x) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadTypeFromString(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int16
|
||||
}{
|
||||
{"request", int16(meshcore.PayloadTypeReq)},
|
||||
{"req", int16(meshcore.PayloadTypeReq)},
|
||||
{"response", int16(meshcore.PayloadTypeResponse)},
|
||||
{"txt_msg", int16(meshcore.PayloadTypeTxtMsg)},
|
||||
{"txtmsg", int16(meshcore.PayloadTypeTxtMsg)},
|
||||
{"text", int16(meshcore.PayloadTypeTxtMsg)},
|
||||
{"direct", int16(meshcore.PayloadTypeTxtMsg)},
|
||||
{"acknowledgement", int16(meshcore.PayloadTypeAck)},
|
||||
{"ack", int16(meshcore.PayloadTypeAck)},
|
||||
{"advertisement", int16(meshcore.PayloadTypeAdvert)},
|
||||
{"advert", int16(meshcore.PayloadTypeAdvert)},
|
||||
{"grp_txt", int16(meshcore.PayloadTypeGrpTxt)},
|
||||
{"group_text", int16(meshcore.PayloadTypeGrpTxt)},
|
||||
{"group", int16(meshcore.PayloadTypeGrpTxt)},
|
||||
{"path", int16(meshcore.PayloadTypePath)},
|
||||
{"trace", int16(meshcore.PayloadTypeTrace)},
|
||||
{"multipart", int16(meshcore.PayloadTypeMultiPart)},
|
||||
{"multi-part", int16(meshcore.PayloadTypeMultiPart)},
|
||||
{"control", int16(meshcore.PayloadTypeControl)},
|
||||
{"raw_custom", int16(meshcore.PayloadTypeRawCustom)},
|
||||
{"raw", int16(meshcore.PayloadTypeRawCustom)},
|
||||
{"ADVERT", int16(meshcore.PayloadTypeAdvert)}, // case insensitive
|
||||
{"", -1},
|
||||
{"garbage", -1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := api.PayloadTypeFromString(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("PayloadTypeFromString(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteTypeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int16
|
||||
want string
|
||||
}{
|
||||
{int16(meshcore.RouteTypeFlood), "FLOOD"},
|
||||
{int16(meshcore.RouteTypeDirect), "DIRECT"},
|
||||
{int16(meshcore.RouteTypeTransportFlood), "TRANSPORT_FLOOD"},
|
||||
{int16(meshcore.RouteTypeTransportDirect), "TRANSPORT_DIRECT"},
|
||||
{99, "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := api.RouteTypeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("RouteTypeName(%d) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-1
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package api defines the response types and read interface for the Beacon REST API.
|
||||
package api
|
||||
|
||||
@@ -22,32 +25,40 @@ type Reader interface {
|
||||
// ListIATAs returns all known IATA codes with display name and coordinates.
|
||||
// IATAs are auto-created on first packet arrival from that location.
|
||||
ListIATAs(ctx context.Context) ([]IATA, error)
|
||||
|
||||
// GetIATA returns a single IATA code by its 3-letter identifier.
|
||||
// Returns nil, error if the IATA code is not found.
|
||||
GetIATA(ctx context.Context, iata string) (*IATA, error)
|
||||
|
||||
// ListRegions returns a summary list of all regions ordered by display_order then name.
|
||||
// Use GetRegion for full detail including associated IATAs.
|
||||
ListRegions(ctx context.Context) ([]RegionSummary, error)
|
||||
|
||||
// GetRegion returns full detail for a single region including its associated IATA codes.
|
||||
// Returns nil, pgx.ErrNoRows if the region is not found.
|
||||
GetRegion(ctx context.Context, regionID int32) (*Region, error)
|
||||
|
||||
// GetRegionBySlug returns full detail for a single region by its URL-safe slug.
|
||||
// Returns nil, pgx.ErrNoRows if the region is not found.
|
||||
GetRegionBySlug(ctx context.Context, slug string) (*Region, error)
|
||||
|
||||
// ListChannels returns a paginated list of channels ordered by last seen.
|
||||
// Includes both hashtag-derived and explicit key channels.
|
||||
// Pass nil hash to skip hash filtering. Pass empty string iata to return all channels.
|
||||
// cursor is last_seen epoch ms of the last item; pass 0 to start from the beginning.
|
||||
ListChannels(ctx context.Context, limit int32, hash []byte, iata string, cursor int64) (Page[ChannelSummary], error)
|
||||
|
||||
// GetChannel returns full detail for a single channel by its integer ID.
|
||||
// Returns nil, pgx.ErrNoRows if the channel is not found.
|
||||
GetChannel(ctx context.Context, channelID int32) (*Channel, error)
|
||||
|
||||
// ListChannelMessages returns paginated messages for a channel identified by its integer ID.
|
||||
// Used by the /channels/{id}/messages endpoint.
|
||||
// Pass a zero time.Time for since to return all messages up to limit.
|
||||
// Pass empty string iata to return messages from all IATAs.
|
||||
// Pass cursor=0 to start from the beginning.
|
||||
ListChannelMessages(ctx context.Context, channelID *int32, since time.Time, limit int32, iatas []string, scope string, cursor int64) (Page[ChannelMessage], error)
|
||||
|
||||
// ListChannelMessagesByHash returns paginated messages for all channels matching the given hash.
|
||||
// Used by the /messages?hash= endpoint. May return messages from multiple channels
|
||||
// if the hash collides across different keys.
|
||||
@@ -55,21 +66,27 @@ type Reader interface {
|
||||
// Pass empty string iata to return messages from all IATAs.
|
||||
// Pass cursor=0 to start from the beginning.
|
||||
ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32, iatas []string, scope string, cursor int64) (Page[ChannelMessage], error)
|
||||
|
||||
// ListMessagesAfterID returns channel messages after the given message ID,
|
||||
// ordered oldest first. Used for WS reconnect backfill.
|
||||
ListMessagesAfterID(ctx context.Context, afterID int64, iatas []string, scope string, limit int32) ([]ChannelMessage, error)
|
||||
|
||||
// ListObservers returns a paginated list of observers with optional filters.
|
||||
// All filter params are optional — pass empty string or nil to skip a filter.
|
||||
// status is "online" or "offline" derived from last_status_at recency.
|
||||
// cursor is last_seen epoch ms of the last observer; pass 0 to start from the beginning.
|
||||
ListObservers(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, limit int32) (Page[ObserverSummary], error)
|
||||
|
||||
// GetObserver returns full detail for a single observer by UUID.
|
||||
// Returns nil, pgx.ErrNoRows if the observer is not found.
|
||||
GetObserver(ctx context.Context, observerID uuid.UUID) (*Observer, error)
|
||||
|
||||
// GetObserverTelemetry returns telemetry points for an observer within the given time range.
|
||||
// since and until define the window; pass zero times to use defaults (last 24h).
|
||||
GetObserverTelemetry(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*ObserverTelemetry, error)
|
||||
// TODO: add interval time.Duration param for server-side bucketing
|
||||
|
||||
// GetObserverTelemetryBucketed returns telemetry points for an observer bucketed into N-hour intervals.
|
||||
GetObserverTelemetryBucketed(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]ObserverTelemetryPoint, error)
|
||||
|
||||
// GetObserverScopes returns the names of all transport scopes an observer has
|
||||
// been seen forwarding packets for, ordered alphabetically.
|
||||
@@ -78,6 +95,7 @@ type Reader interface {
|
||||
// ListObserverAdverts returns a paginated list of advert packets heard by an observer.
|
||||
// Pass cursor=0 to start from the beginning.
|
||||
ListObserverAdverts(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (Page[AdvertObservation], error)
|
||||
|
||||
// ListNodes returns a paginated list of nodes with optional filters.
|
||||
// Pass 0 for nodeType, nil iatas, nil for pubkey to skip those filters.
|
||||
// cursor is last_seen epoch ms; pass 0 to start from the beginning.
|
||||
@@ -86,59 +104,94 @@ type Reader interface {
|
||||
// GetNode returns full detail for a single node by UUID.
|
||||
// Returns nil, pgx.ErrNoRows if the node is not found.
|
||||
GetNode(ctx context.Context, nodeID uuid.UUID) (*Node, error)
|
||||
|
||||
// GetNodesByIDs returns a map of node ID to resolved node details for the given IDs.
|
||||
GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]*ResolvedNode, error)
|
||||
|
||||
// ListNodeObservations returns a paginated list of packet observations originating from a node.
|
||||
// Pass cursor=0 to start from the beginning.
|
||||
ListNodeObservations(ctx context.Context, nodeID uuid.UUID, cursor int64, limit int32) (Page[PacketObservationSummary], error)
|
||||
|
||||
// ListPackets returns a paginated list of packets with the latest observation rolled in.
|
||||
// Pass 0 for payloadType/routeType to skip those filters.
|
||||
// Pass nil for iatas, zero times for since/until to skip those filters.
|
||||
// cursor is last_heard_at epoch ms; pass 0 to start from the beginning.
|
||||
ListPackets(ctx context.Context, payloadType, routeType int16, iatas []string, scope string, since, until time.Time, cursor int64, limit int32) (Page[PacketSummary], error)
|
||||
|
||||
// ListPacketsAfterID returns packets with observations after the given observation ID,
|
||||
// ordered oldest first. Used for WS reconnect backfill.
|
||||
ListPacketsAfterID(ctx context.Context, afterObservationID int64, payloadType, routeType int16, iatas []string, scope string, limit int32) ([]PacketSummary, error)
|
||||
|
||||
// GetPacket returns full packet detail including all observations with radio settings.
|
||||
// Returns nil, pgx.ErrNoRows if not found.
|
||||
GetPacket(ctx context.Context, packetHash []byte) (*Packet, error)
|
||||
// GetRadioPresets returns radio preset usage grouped by preset and IATA.
|
||||
// Pass empty string for preset or iata to skip those filters.
|
||||
GetRadioPresets(ctx context.Context, preset, iata string) ([]RadioPreset, error)
|
||||
|
||||
// GetStatsOverview returns top-line network figures for the last 24 hours.
|
||||
// Pass empty string iata to return stats across all IATAs.
|
||||
GetStatsOverview(ctx context.Context, iata string) (*StatsOverview, error)
|
||||
|
||||
// GetStatsObservations returns hourly observation counts for charting.
|
||||
// Pass empty string iata to return stats across all IATAs.
|
||||
// since defines the start of the window; pass zero time for default (last 7 days).
|
||||
GetStatsObservations(ctx context.Context, iata string, since time.Time) ([]ObservationPoint, error)
|
||||
|
||||
// GetStatsPayloadBreakdown returns observation counts grouped by payload type.
|
||||
// Pass empty string iata to return stats across all IATAs.
|
||||
// since defines the start of the window; pass zero time for default (last 24h).
|
||||
GetStatsPayloadBreakdown(ctx context.Context, iata string, since time.Time) ([]PayloadBreakdownItem, error)
|
||||
|
||||
// GetStatsTopNodes returns the top N nodes by observation count.
|
||||
// Pass empty string iata to return stats across all IATAs.
|
||||
GetStatsTopNodes(ctx context.Context, iata string, limit int32) ([]TopNode, error)
|
||||
|
||||
// GetStatsTopObservers returns the top N observers by observation count.
|
||||
// Pass empty string iata to return stats across all IATAs.
|
||||
// since defines the start of the window; pass zero time for default (last 24h).
|
||||
GetStatsTopObservers(ctx context.Context, iata string, since time.Time, limit int32) ([]TopObserver, error)
|
||||
|
||||
// GetScopeStats returns aggregate packet, observer and node counts per transport scope.
|
||||
GetScopeStats(ctx context.Context) ([]ScopeStats, error)
|
||||
|
||||
// GetScopeNames returns the names of all configured transport scopes, ordered alphabetically.
|
||||
// Use when no geographic filter is applied — returns names only for a lightweight response.
|
||||
GetScopeNames(ctx context.Context) ([]string, error)
|
||||
|
||||
// GetScopesByIATAs returns scope summaries filtered by the given IATA codes,
|
||||
// including observer, node and IATA counts. Expands region/regionId to IATAs automatically.
|
||||
GetScopesByIATAs(ctx context.Context, iatas []string) ([]ScopeSummary, error)
|
||||
|
||||
// GetScopeByName returns full detail for a single scope by its normalized name (e.g. "#bc"),
|
||||
// including packet count, observer count, node count, and the list of IATAs it is active in.
|
||||
// Returns nil if the scope is not found.
|
||||
GetScopeByName(ctx context.Context, name string) (*ScopeDetail, error)
|
||||
|
||||
// ListTraceTags returns a paginated list of trace tags with aggregate metadata.
|
||||
ListTraceTags(ctx context.Context, iatas []string, scope string, since, until time.Time, cursor time.Time, limit int32) ([]TraceTagSummary, error)
|
||||
|
||||
// GetTraceByTag returns all packets for a given trace tag with resolved routes.
|
||||
GetTraceByTag(ctx context.Context, tag string) (*TraceDetail, error)
|
||||
|
||||
// ListKnownRoutes returns known routes filtered by IATA and optional hop count.
|
||||
ListKnownRoutes(ctx context.Context, iata string, hopCount int32, cursor time.Time, limit int32) ([]KnownRoute, error)
|
||||
|
||||
// SearchKnownRoutes returns known routes containing a path from source to destination hash.
|
||||
SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash string) ([]KnownRoute, error)
|
||||
|
||||
// GetNodeNeighbors returns the neighbors of a node ordered by most recently seen.
|
||||
GetNodeNeighbors(ctx context.Context, nodeID uuid.UUID) ([]NodeNeighbor, error)
|
||||
|
||||
// GetKnownRoutesByNode returns all known routes in a given IATA that contain
|
||||
// the specified node UUID anywhere in their hop sequence.
|
||||
GetKnownRoutesByNode(ctx context.Context, iata string, nodeID uuid.UUID) ([]KnownRoute, error)
|
||||
|
||||
// GetCrossIATANeighbors returns neighbors of a node that were observed in a
|
||||
// different IATA — indicating a potential cross-IATA radio link.
|
||||
GetCrossIATANeighbors(ctx context.Context, nodeID uuid.UUID, iata string) ([]NodeNeighbor, error)
|
||||
|
||||
// SearchCrossIATARoutes finds routes that cross IATA boundaries between
|
||||
// a source node/IATA and a destination node/IATA.
|
||||
SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]CrossIATARoute, error)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
// RegionSummary is the minimal region representation used in list responses.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package router wires all HTTP routes onto the Chi router and injects
|
||||
// dependencies (hub, reader, ingest workers) into the handler closures.
|
||||
// All routes are mounted under /api/v1 with public and private groups
|
||||
|
||||
+27
-6
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
import "github.com/google/uuid"
|
||||
@@ -12,10 +15,28 @@ type RouteHop struct {
|
||||
// KnownRoute is a fully resolved path through the mesh where all hops
|
||||
// have been confirmed as high confidence.
|
||||
type KnownRoute struct {
|
||||
ID int64 `json:"id"`
|
||||
IATA string `json:"iata"`
|
||||
HopCount int32 `json:"hopCount"`
|
||||
Hops []RouteHop `json:"hops"`
|
||||
FirstSeen int64 `json:"firstSeen"` // epoch ms
|
||||
LastSeen int64 `json:"lastSeen"` // epoch ms
|
||||
ID int64 `json:"id"`
|
||||
IATA string `json:"iata"`
|
||||
HopCount int32 `json:"hopCount"`
|
||||
Hops []RouteHop `json:"hops"`
|
||||
FirstSeen int64 `json:"firstSeen"` // epoch ms
|
||||
LastSeen int64 `json:"lastSeen"` // epoch ms
|
||||
ObservationCount int64 `json:"observationCount"`
|
||||
}
|
||||
|
||||
// CrossIATAHop represents the boundary hop between two IATAs in a cross-IATA route.
|
||||
type CrossIATAHop struct {
|
||||
FromNode ResolvedNode `json:"fromNode"` // last node in source IATA
|
||||
ToNode ResolvedNode `json:"toNode"` // first node in target IATA
|
||||
FromIATA string `json:"fromIata"`
|
||||
ToIATA string `json:"toIata"`
|
||||
LastSeen int64 `json:"lastSeen"` // epoch ms
|
||||
}
|
||||
|
||||
// CrossIATARoute is a route that crosses IATA boundaries.
|
||||
type CrossIATARoute struct {
|
||||
SourceSegment []RouteHop `json:"sourceSegment"` // route segment in source IATA
|
||||
CrossHop CrossIATAHop `json:"crossHop"` // the boundary hop
|
||||
TargetSegment []RouteHop `json:"targetSegment"` // route segment in target IATA
|
||||
TotalHops int `json:"totalHops"`
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
// ScopeSummary is the minimal scope representation used in filtered list responses.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
// TraceTagSummary is a single trace tag with aggregate metadata.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package config loads the Beacon configuration file and seeds the database
|
||||
// with regions, IATA overrides, and channel keys on startup.
|
||||
package config
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeScopeName_WithHash(t *testing.T) {
|
||||
if normalizeScopeName("#bc") != "#bc" {
|
||||
t.Error("expected #bc unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeScopeName_WithDollar(t *testing.T) {
|
||||
if normalizeScopeName("$bc") != "$bc" {
|
||||
t.Error("expected $bc unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeScopeName_WithoutPrefix(t *testing.T) {
|
||||
if normalizeScopeName("bc") != "#bc" {
|
||||
t.Error("expected bc to become #bc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeScopeName_Empty(t *testing.T) {
|
||||
if normalizeScopeName("") != "#" {
|
||||
t.Error("expected empty string to become #")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveScopeKey_Length(t *testing.T) {
|
||||
key := deriveScopeKey("#bc")
|
||||
if len(key) != 16 {
|
||||
t.Errorf("expected 16 bytes, got %d", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveScopeKey_Deterministic(t *testing.T) {
|
||||
a := deriveScopeKey("#bc")
|
||||
b := deriveScopeKey("#bc")
|
||||
if hex.EncodeToString(a) != hex.EncodeToString(b) {
|
||||
t.Error("expected same key for same input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveScopeKey_KnownValue(t *testing.T) {
|
||||
// SHA256("#bc")[:16] — pin the exact derivation so changes are caught
|
||||
key := deriveScopeKey("#bc")
|
||||
got := hex.EncodeToString(key)
|
||||
// generate this once: echo -n "#bc" | sha256sum | cut -c1-32
|
||||
const want = "84509cfe73d94f7f6a8299e6bcdb8a3c"
|
||||
if got != want {
|
||||
t.Errorf("deriveScopeKey(\"#bc\") = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveScopeKey_DifferentInputs(t *testing.T) {
|
||||
a := deriveScopeKey("#bc")
|
||||
b := deriveScopeKey("#other")
|
||||
if hex.EncodeToString(a) == hex.EncodeToString(b) {
|
||||
t.Error("expected different keys for different inputs")
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package hub provides the central fan-out broker between the MQTT ingest
|
||||
// goroutines and connected WebSocket clients.
|
||||
//
|
||||
@@ -47,7 +50,6 @@ type Event struct {
|
||||
// An empty non-nil slice means "match nothing on this dimension".
|
||||
type Scope struct {
|
||||
IATAs []string
|
||||
RegionIATAs []string // pre-expanded from regionId by the WS handler
|
||||
PayloadTypes []uint8
|
||||
ChannelHashes []string
|
||||
Events []EventType
|
||||
@@ -88,11 +90,8 @@ func scopeMatches(s Scope, e Event) bool {
|
||||
if len(s.Events) > 0 && !slices.Contains(s.Events, e.Type) {
|
||||
return false
|
||||
}
|
||||
if len(s.IATAs) > 0 || len(s.RegionIATAs) > 0 {
|
||||
allIATAs := append(s.IATAs, s.RegionIATAs...) //nolint:gocritic
|
||||
if !slices.Contains(allIATAs, e.IATA) {
|
||||
return false
|
||||
}
|
||||
if len(s.IATAs) > 0 && !slices.Contains(s.IATAs, e.IATA) {
|
||||
return false
|
||||
}
|
||||
if len(s.PayloadTypes) > 0 && !slices.Contains(s.PayloadTypes, e.PayloadType) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package hub
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScopeMatches_EmptyScope(t *testing.T) {
|
||||
// empty scope matches everything — no filters means no restrictions
|
||||
s := Scope{}
|
||||
e := Event{Type: EventPacketObservation, IATA: "YVR", PayloadType: 4}
|
||||
if !scopeMatches(s, e) {
|
||||
t.Error("empty scope should match all events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeMatches_EventFilter(t *testing.T) {
|
||||
s := Scope{Events: []EventType{EventNodeUpdate}}
|
||||
if !scopeMatches(s, Event{Type: EventNodeUpdate, IATA: "YVR"}) {
|
||||
t.Error("expected nodeUpdate to match")
|
||||
}
|
||||
if scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YVR"}) {
|
||||
t.Error("expected packetObservation not to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeMatches_IATAFilter(t *testing.T) {
|
||||
s := Scope{Events: []EventType{EventPacketObservation}, IATAs: []string{"YVR", "YYJ"}}
|
||||
if !scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YVR"}) {
|
||||
t.Error("expected YVR to match")
|
||||
}
|
||||
if !scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YYJ"}) {
|
||||
t.Error("expected YYJ to match")
|
||||
}
|
||||
if scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YYC"}) {
|
||||
t.Error("expected YYC not to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeMatches_PayloadTypeFilter(t *testing.T) {
|
||||
s := Scope{Events: []EventType{EventPacketObservation}, PayloadTypes: []uint8{4}}
|
||||
if !scopeMatches(s, Event{Type: EventPacketObservation, PayloadType: 4}) {
|
||||
t.Error("expected payload type 4 to match")
|
||||
}
|
||||
if scopeMatches(s, Event{Type: EventPacketObservation, PayloadType: 5}) {
|
||||
t.Error("expected payload type 5 not to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeMatches_ChannelHashFilter(t *testing.T) {
|
||||
s := Scope{Events: []EventType{EventChannelMessage}, ChannelHashes: []string{"ab"}}
|
||||
if !scopeMatches(s, Event{Type: EventChannelMessage, ChannelHash: "ab"}) {
|
||||
t.Error("expected channel hash ab to match")
|
||||
}
|
||||
if scopeMatches(s, Event{Type: EventChannelMessage, ChannelHash: "cd"}) {
|
||||
t.Error("expected channel hash cd not to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeMatches_AllFiltersPass(t *testing.T) {
|
||||
s := Scope{
|
||||
Events: []EventType{EventPacketObservation},
|
||||
IATAs: []string{"YVR"},
|
||||
PayloadTypes: []uint8{4},
|
||||
}
|
||||
if !scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YVR", PayloadType: 4}) {
|
||||
t.Error("expected all-matching event to pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeMatches_OneFilterFails(t *testing.T) {
|
||||
s := Scope{
|
||||
Events: []EventType{EventPacketObservation},
|
||||
IATAs: []string{"YVR"},
|
||||
PayloadTypes: []uint8{4},
|
||||
}
|
||||
if scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YYC", PayloadType: 4}) {
|
||||
t.Error("expected wrong IATA to fail")
|
||||
}
|
||||
if scopeMatches(s, Event{Type: EventPacketObservation, IATA: "YVR", PayloadType: 5}) {
|
||||
t.Error("expected wrong payload type to fail")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// gen generates internal/iatadb/db.go from the OurAirports airports.csv dataset.
|
||||
//
|
||||
// Usage (from repo root):
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package iatadb provides a static mapping from IATA airport codes to
|
||||
// geographic metadata (country and continent).
|
||||
//
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package iatadb_test
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package ingest
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package ingest subscribes to a single MeshCore MQTT broker and drives the
|
||||
// observation pipeline described in the design doc.
|
||||
//
|
||||
@@ -119,6 +122,12 @@ type DB interface {
|
||||
// GetObserverRadio returns the current radio settings for the given observer.
|
||||
GetObserverRadio(ctx context.Context, observerID uuid.UUID) (RadioSettings, error)
|
||||
|
||||
// IsObserverByPubkey returns true if the given public key belongs to a known observer.
|
||||
IsObserverByPubkey(ctx context.Context, pubkey []byte) bool
|
||||
|
||||
// GetObserverScopes returns the list of scope names associated with the given observer.
|
||||
GetObserverScopes(ctx context.Context, observerID uuid.UUID) ([]string, error)
|
||||
|
||||
// ResolvePathHashes returns a list of node UUIDs for the given path hash prefixes and IATA.
|
||||
ResolvePathHashes(ctx context.Context, iata string, hashes [][]byte) (map[string][]api.ResolvedPathEntry, error)
|
||||
|
||||
@@ -145,6 +154,10 @@ type DB interface {
|
||||
|
||||
// UpsertKnownRoute stores a fully resolved path where all hops have high confidence.
|
||||
UpsertKnownRoute(ctx context.Context, nodeIDs []uuid.UUID, hashPrefix [][]byte, iata string, hopCount int32) error
|
||||
|
||||
// UpsertNodeNeighbor records or updates a neighbor relationship between two nodes.
|
||||
// nodeID is the advertising node, neighborID is the first-hop forwarder.
|
||||
UpsertNodeNeighbor(ctx context.Context, nodeID, neighborID uuid.UUID, iata string) error
|
||||
}
|
||||
|
||||
// ChannelKeyStore is a read-only view of the channel keys loaded from config.
|
||||
@@ -318,7 +331,7 @@ func normalizeObserverType(source string) string {
|
||||
return ""
|
||||
}
|
||||
s := source
|
||||
// strip org prefix e.g. "meshcore-dev/meshcore-ha" → "meshcore-ha"
|
||||
// strip org/path prefix e.g. "meshcore-dev/meshcore-ha" → "meshcore-ha"
|
||||
if i := strings.LastIndex(s, "/"); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseNumber_Float(t *testing.T) {
|
||||
raw := json.RawMessage(`3.14`)
|
||||
if parseNumber(raw) != 3.14 {
|
||||
t.Errorf("expected 3.14, got %f", parseNumber(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNumber_QuotedString(t *testing.T) {
|
||||
raw := json.RawMessage(`"42.5"`)
|
||||
if parseNumber(raw) != 42.5 {
|
||||
t.Errorf("expected 42.5, got %f", parseNumber(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNumber_Empty(t *testing.T) {
|
||||
if parseNumber(json.RawMessage(``)) != 0 {
|
||||
t.Error("expected 0 for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNumber_Invalid(t *testing.T) {
|
||||
if parseNumber(json.RawMessage(`"notanumber"`)) != 0 {
|
||||
t.Error("expected 0 for unparseable string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNumber_Integer(t *testing.T) {
|
||||
raw := json.RawMessage(`7`)
|
||||
if parseNumber(raw) != 7 {
|
||||
t.Errorf("expected 7, got %f", parseNumber(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeObserverType_OrgPrefix(t *testing.T) {
|
||||
if normalizeObserverType("meshcore-dev/meshcore-ha") != "meshcore-ha" {
|
||||
t.Errorf("unexpected: %s", normalizeObserverType("meshcore-dev/meshcore-ha"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeObserverType_VersionSuffix(t *testing.T) {
|
||||
if normalizeObserverType("meshcoretomqtt:1.1.0") != "meshcoretomqtt" {
|
||||
t.Errorf("unexpected: %s", normalizeObserverType("meshcoretomqtt:1.1.0"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeObserverType_BuildSuffix(t *testing.T) {
|
||||
// org/name format — LastIndex strips to just the name portion
|
||||
if normalizeObserverType("meshcore-dev/meshcoretomqtt") != "meshcoretomqtt" {
|
||||
t.Errorf("unexpected: %s", normalizeObserverType("meshcore-dev/meshcoretomqtt"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeObserverType_Plain(t *testing.T) {
|
||||
if normalizeObserverType("meshcoretomqtt") != "meshcoretomqtt" {
|
||||
t.Errorf("unexpected: %s", normalizeObserverType("meshcoretomqtt"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeObserverType_Empty(t *testing.T) {
|
||||
if normalizeObserverType("") != "" {
|
||||
t.Error("expected empty string for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferObserverType_SourceTakesPriority(t *testing.T) {
|
||||
got := inferObserverType("meshcore-dev/meshcore-ha", "some-version")
|
||||
if got != "meshcore-ha" {
|
||||
t.Errorf("expected meshcore-ha, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferObserverType_FallsBackToClientVersion(t *testing.T) {
|
||||
got := inferObserverType("", "custom-firmware-1.0")
|
||||
if got != "custom-firmware-1.0" {
|
||||
t.Errorf("expected custom-firmware-1.0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferObserverType_BothEmpty(t *testing.T) {
|
||||
if inferObserverType("", "") != "" {
|
||||
t.Error("expected empty string when both inputs are empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint32ToBytes_KnownValue(t *testing.T) {
|
||||
b := uint32ToBytes(0x01020304)
|
||||
// little-endian: least significant byte first
|
||||
expected := []byte{0x04, 0x03, 0x02, 0x01}
|
||||
for i, v := range expected {
|
||||
if b[i] != v {
|
||||
t.Errorf("byte %d: expected %02x, got %02x", i, v, b[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint32ToBytes_Zero(t *testing.T) {
|
||||
b := uint32ToBytes(0)
|
||||
if len(b) != 4 {
|
||||
t.Fatalf("expected 4 bytes, got %d", len(b))
|
||||
}
|
||||
for _, v := range b {
|
||||
if v != 0 {
|
||||
t.Error("expected all zero bytes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint32ToBytes_RoundTrip(t *testing.T) {
|
||||
v := uint32(0xDEADBEEF)
|
||||
b := uint32ToBytes(v)
|
||||
got := binary.LittleEndian.Uint32(b)
|
||||
if got != v {
|
||||
t.Errorf("round trip failed: expected %x, got %x", v, got)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package ingest
|
||||
|
||||
import (
|
||||
@@ -543,12 +546,24 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
|
||||
log.Printf("ingest[%s]: db: upsert packet failed from %s/%s: %v", w.cfg.BrokerName, iata, pubkeyHex, err)
|
||||
return
|
||||
}
|
||||
heardAt, err := time.Parse("2006-01-02T15:04:05.000000", envelope.Timestamp)
|
||||
// Try parsing with timezone offset first
|
||||
heardAt, err := time.Parse("2006-01-02T15:04:05.000000-07:00", envelope.Timestamp)
|
||||
if err != nil {
|
||||
heardAt, err = time.Parse("2006-01-02T15:04:05.000000", envelope.Timestamp)
|
||||
}
|
||||
if err != nil {
|
||||
heardAt, err = time.Parse("2006-01-02T15:04:05", envelope.Timestamp)
|
||||
if err != nil {
|
||||
log.Printf("ingest[%s]: error parsing time from %s/%s: %v", w.cfg.BrokerName, iata, pubkeyHex, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("ingest[%s]: failed to parse timestamp %q: %v", w.cfg.BrokerName, envelope.Timestamp, err)
|
||||
heardAt = time.Now().UTC()
|
||||
} else {
|
||||
// clamp to server time if offset is suspicious (> 30 min drift)
|
||||
now := time.Now().UTC()
|
||||
diff := heardAt.UTC().Sub(now)
|
||||
if diff > 30*time.Minute || diff < -30*time.Minute {
|
||||
log.Printf("ingest[%s]: clamping suspicious timestamp %s (diff %v) for pubkey %s", w.cfg.BrokerName, envelope.Timestamp, diff, pubkeyHex[:8])
|
||||
heardAt = now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,7 +634,7 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
|
||||
}
|
||||
w.runCapabilityDetection(ctx, packet.PayloadType(), packet.PathHashSize(), resolvedIDs)
|
||||
if inserted {
|
||||
w.handlePayloadTypeSideEffects(ctx, packet, iata, packetHash[:], radio, scopeID)
|
||||
w.handlePayloadTypeSideEffects(ctx, packet, iata, packetHash[:], radio, scopeID, matchedScope)
|
||||
evt := packetObservationEvent{}
|
||||
evt.PacketHash = hex.EncodeToString(packetHash[:])
|
||||
evt.Packet.PayloadType = packet.PayloadType()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package ingest
|
||||
|
||||
import (
|
||||
@@ -8,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/hub"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/keystore"
|
||||
"github.com/meshcore-go/meshcore-go"
|
||||
@@ -43,19 +47,25 @@ type channelMessageEvent struct {
|
||||
|
||||
// nodeUpdateEvent is the JSON payload for a nodeUpdate WS event.
|
||||
type nodeUpdateEvent struct {
|
||||
NodeID string `json:"nodeId"` // UUID string
|
||||
Name string `json:"name"`
|
||||
NodeType uint8 `json:"nodeType"`
|
||||
IATA string `json:"iata"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lng *float64 `json:"lng,omitempty"`
|
||||
NodeID string `json:"nodeId"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
Name string `json:"name"`
|
||||
NodeType uint8 `json:"nodeType"`
|
||||
NodeTypeName string `json:"nodeTypeName"`
|
||||
IATA string `json:"iata"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lng *float64 `json:"lng,omitempty"`
|
||||
IsObserver bool `json:"isObserver"`
|
||||
IATAs []api.NodeIATA `json:"iatas"`
|
||||
DefaultScope *string `json:"defaultScope,omitempty"`
|
||||
Radio *string `json:"radio,omitempty"`
|
||||
}
|
||||
|
||||
// handlePayloadTypeSideEffects runs payload-type-specific processing after a
|
||||
// new observation is confirmed inserted. Currently handles:
|
||||
// - PayloadTypeAdvert (0x04): upsert node and node_iatas
|
||||
// - PayloadTypeGrpTxt (0x05): decrypt and store channel message if key is known
|
||||
func (w *Worker) handlePayloadTypeSideEffects(ctx context.Context, packet *meshcore.Packet, iata string, packetHash []byte, radio RadioSettings, scopeID *int32) {
|
||||
func (w *Worker) handlePayloadTypeSideEffects(ctx context.Context, packet *meshcore.Packet, iata string, packetHash []byte, radio RadioSettings, scopeID *int32, matchedScope *string) {
|
||||
if packet.PayloadType() == meshcore.PayloadTypeAdvert {
|
||||
advert, err := meshcore.AdvertFromBytes(packet.Payload)
|
||||
if err != nil {
|
||||
@@ -76,11 +86,30 @@ func (w *Worker) handlePayloadTypeSideEffects(ctx context.Context, packet *meshc
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
}
|
||||
nodeID, err := w.db.UpsertNode(ctx, params, radio)
|
||||
var nodeRadio RadioSettings
|
||||
if packet.PathHashCount() == 0 {
|
||||
nodeRadio = radio
|
||||
}
|
||||
nodeID, err := w.db.UpsertNode(ctx, params, nodeRadio)
|
||||
if err != nil {
|
||||
log.Printf("ingest[%s]: db: upsert node failed: %v", w.cfg.BrokerName, err)
|
||||
return
|
||||
}
|
||||
// if the advert was forwarded, the first hop is a neighbor
|
||||
if packet.PathHashCount() > 0 && (advert.Type() == meshcore.AdvertTypeRepeater || advert.Type() == meshcore.AdvertTypeRoom) {
|
||||
firstHop := packet.PathHashes()
|
||||
if len(firstHop) > 0 {
|
||||
resolved, err := w.db.ResolvePathHashes(ctx, iata, firstHop[:1])
|
||||
if err == nil {
|
||||
key := hex.EncodeToString(firstHop[0])
|
||||
if entries := resolved[key]; len(entries) == 1 {
|
||||
if err := w.db.UpsertNodeNeighbor(ctx, nodeID, entries[0].NodeID, iata); err != nil {
|
||||
log.Printf("ingest[%s]: failed to upsert node neighbor: %v", w.cfg.BrokerName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := w.db.UpsertNodeIATA(ctx, nodeID, iata); err != nil {
|
||||
log.Printf("ingest[%s]: db: upsert node IATA failed: %v", w.cfg.BrokerName, err)
|
||||
}
|
||||
@@ -93,13 +122,30 @@ func (w *Worker) handlePayloadTypeSideEffects(ctx context.Context, packet *meshc
|
||||
if err := w.db.UpsertNodeShortID(ctx, nodeID, iata, prefix4); err != nil {
|
||||
log.Printf("ingest[%s]: failed to upsert node short ID for %s: %v", w.cfg.BrokerName, hex.EncodeToString(prefix4), err)
|
||||
}
|
||||
pubkeyHex := hex.EncodeToString(advert.PublicKey.PublicKeyBytes())
|
||||
isObserver := w.db.IsObserverByPubkey(ctx, advert.PublicKey.PublicKeyBytes())
|
||||
var defaultScope *string
|
||||
if matchedScope != nil {
|
||||
defaultScope = matchedScope
|
||||
}
|
||||
var radioStr *string
|
||||
if radio.FreqMHz != 0 {
|
||||
s := fmt.Sprintf("%.1f,%g,%d", radio.FreqMHz, radio.BWKHz, radio.SF)
|
||||
radioStr = &s
|
||||
}
|
||||
evt := nodeUpdateEvent{
|
||||
NodeID: nodeID.String(),
|
||||
Name: advert.AppData().Name,
|
||||
NodeType: advert.Type(),
|
||||
IATA: iata,
|
||||
Lat: lat,
|
||||
Lng: lon,
|
||||
NodeID: nodeID.String(),
|
||||
PublicKey: pubkeyHex,
|
||||
Name: advert.AppData().Name,
|
||||
NodeType: advert.Type(),
|
||||
NodeTypeName: api.NodeTypeName(int16(advert.Type())),
|
||||
IATA: iata,
|
||||
Lat: lat,
|
||||
Lng: lon,
|
||||
IsObserver: isObserver,
|
||||
IATAs: []api.NodeIATA{{IATA: iata, LastHeard: time.Now().UnixMilli()}},
|
||||
DefaultScope: defaultScope,
|
||||
Radio: radioStr,
|
||||
}
|
||||
w.broadcast(hub.EventNodeUpdate, iata, meshcore.PayloadTypeAdvert, "", evt)
|
||||
return
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -34,13 +38,16 @@ type UpdateObserverStatusParams struct {
|
||||
// statusEvent is the JSON payload for an observerStatus WS event.
|
||||
// Shape matches the design doc § Server → Client events.
|
||||
type statusEvent struct {
|
||||
ObserverID string `json:"observerId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IATA string `json:"iata,omitempty"`
|
||||
Online bool `json:"online"`
|
||||
BatteryMV int `json:"batteryMv,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
LastStatusAt int64 `json:"lastStatusAt"` // epoch ms
|
||||
ObserverID string `json:"observerId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ObserverType *string `json:"observerType,omitempty"`
|
||||
IATA string `json:"iata,omitempty"`
|
||||
Online bool `json:"online"`
|
||||
Radio *string `json:"radio,omitempty"`
|
||||
Scopes []string `json:"scopes"`
|
||||
BatteryMV int `json:"batteryMv,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
LastStatusAt int64 `json:"lastStatusAt"`
|
||||
}
|
||||
|
||||
// handleStatus processes a /status message and fans out an observerStatus event.
|
||||
@@ -172,11 +179,28 @@ func (w *Worker) handleStatus(ctx context.Context, pubkeyHex string, raw []byte)
|
||||
if err != nil {
|
||||
iata = "" // non-fatal, continue
|
||||
}
|
||||
scopes, err := w.db.GetObserverScopes(ctx, observerID)
|
||||
if err != nil {
|
||||
log.Printf("ingest[%s]: failed to get observer scopes for %s: %v", w.cfg.BrokerName, pubkeyHex, err)
|
||||
scopes = []string{}
|
||||
}
|
||||
var radioStr *string
|
||||
if params.RadioFreqMHz != 0 {
|
||||
s := fmt.Sprintf("%.1f,%g,%d", params.RadioFreqMHz, params.RadioBWKHz, params.RadioSF)
|
||||
radioStr = &s
|
||||
}
|
||||
var observerType *string
|
||||
if envelope.ObserverType != "" {
|
||||
observerType = &envelope.ObserverType
|
||||
}
|
||||
evt := statusEvent{
|
||||
ObserverID: observerID.String(),
|
||||
DisplayName: envelope.DisplayName,
|
||||
ObserverType: observerType,
|
||||
IATA: iata,
|
||||
Online: true,
|
||||
Radio: radioStr,
|
||||
Scopes: scopes,
|
||||
BatteryMV: envelope.Stats.BatteryMV,
|
||||
UptimeSeconds: envelope.Stats.UptimeSeconds,
|
||||
LastStatusAt: time.Now().UnixMilli(),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package keystore provides channel key lookup for the ingest pipeline.
|
||||
// Keys are loaded from config at startup and never written at runtime.
|
||||
// Future: add a DB-backed fallback that checks the channel_keys table.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package keystore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveHashtagKey_SecretLength(t *testing.T) {
|
||||
secret, _, _ := DeriveHashtagKey("bc")
|
||||
if len(secret) != 16 {
|
||||
t.Errorf("expected 16 byte secret, got %d", len(secret))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveHashtagKey_Deterministic(t *testing.T) {
|
||||
s1, h1, f1 := DeriveHashtagKey("bc")
|
||||
s2, h2, f2 := DeriveHashtagKey("bc")
|
||||
if !bytes.Equal(s1, s2) {
|
||||
t.Error("secret not deterministic")
|
||||
}
|
||||
if h1 != h2 {
|
||||
t.Error("channelHash not deterministic")
|
||||
}
|
||||
if !bytes.Equal(f1, f2) {
|
||||
t.Error("fingerprint not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveHashtagKey_DifferentInputs(t *testing.T) {
|
||||
s1, _, _ := DeriveHashtagKey("bc")
|
||||
s2, _, _ := DeriveHashtagKey("other")
|
||||
if bytes.Equal(s1, s2) {
|
||||
t.Error("expected different secrets for different inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveHashtagKey_DerivationSpec(t *testing.T) {
|
||||
// secret = SHA256("#bc")[:16]
|
||||
// channel_hash = SHA256(secret)[0]
|
||||
// fingerprint = SHA256(secret)[:8]
|
||||
tag := "bc"
|
||||
input := sha256.Sum256([]byte("#" + tag))
|
||||
expectedSecret := input[:16]
|
||||
expectedSecretHash := sha256.Sum256(expectedSecret)
|
||||
expectedChannelHash := expectedSecretHash[0]
|
||||
expectedFingerprint := expectedSecretHash[:8]
|
||||
|
||||
secret, channelHash, fingerprint := DeriveHashtagKey(tag)
|
||||
|
||||
if !bytes.Equal(secret, expectedSecret) {
|
||||
t.Errorf("secret mismatch: got %s, want %s", hex.EncodeToString(secret), hex.EncodeToString(expectedSecret))
|
||||
}
|
||||
if channelHash != expectedChannelHash {
|
||||
t.Errorf("channelHash mismatch: got %02x, want %02x", channelHash, expectedChannelHash)
|
||||
}
|
||||
if !bytes.Equal(fingerprint, expectedFingerprint) {
|
||||
t.Errorf("fingerprint mismatch: got %s, want %s", hex.EncodeToString(fingerprint), hex.EncodeToString(expectedFingerprint))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_Length(t *testing.T) {
|
||||
fp := Fingerprint([]byte("somekey"))
|
||||
if len(fp) != 8 {
|
||||
t.Errorf("expected 8 bytes, got %d", len(fp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_Deterministic(t *testing.T) {
|
||||
key := []byte("somekey")
|
||||
if !bytes.Equal(Fingerprint(key), Fingerprint(key)) {
|
||||
t.Error("fingerprint not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_MatchesSHA256Prefix(t *testing.T) {
|
||||
key := []byte("somekey")
|
||||
h := sha256.Sum256(key)
|
||||
expected := h[:8]
|
||||
if !bytes.Equal(Fingerprint(key), expected) {
|
||||
t.Error("fingerprint does not match SHA256(key)[:8]")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package scopestore provides an in-memory lookup of transport scope keys
|
||||
// loaded from the database at startup.
|
||||
package scopestore
|
||||
|
||||
+15
-4
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// Package ws handles the WebSocket endpoint at GET /ws.
|
||||
//
|
||||
// Protocol (from design doc):
|
||||
@@ -155,6 +158,7 @@ type clientMessage struct {
|
||||
type subscribeScope struct {
|
||||
IATAs []string `json:"iatas"`
|
||||
RegionIDs []string `json:"regionIds"`
|
||||
RegionSlugs []string `json:"reagionSlugs"`
|
||||
PayloadTypes []uint8 `json:"payloadTypes"`
|
||||
RouteTypes []uint8 `json:"routeTypes"`
|
||||
ChannelHashes []string `json:"channelHashes"`
|
||||
@@ -175,7 +179,7 @@ func handleClientMessage(ctx context.Context, client *hub.Client, reader api.Rea
|
||||
if msg.Scope == nil {
|
||||
return
|
||||
}
|
||||
var regionIATAs []string
|
||||
iatas := msg.Scope.IATAs
|
||||
for _, ridStr := range msg.Scope.RegionIDs {
|
||||
rid, err := strconv.Atoi(ridStr)
|
||||
if err != nil {
|
||||
@@ -187,14 +191,21 @@ func handleClientMessage(ctx context.Context, client *hub.Client, reader api.Rea
|
||||
log.Printf("ws[%s]: region %d not found, skipping: %v", connID, rid, err)
|
||||
continue
|
||||
}
|
||||
regionIATAs = append(regionIATAs, region.IATAs...)
|
||||
iatas = append(iatas, region.IATAs...)
|
||||
}
|
||||
for _, slug := range msg.Scope.RegionSlugs {
|
||||
region, err := reader.GetRegionBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
log.Printf("ws[%s]: region slug %q not found, skipping: %v", connID, slug, err)
|
||||
continue
|
||||
}
|
||||
iatas = append(iatas, region.IATAs...)
|
||||
}
|
||||
scope := hub.Scope{
|
||||
IATAs: msg.Scope.IATAs,
|
||||
IATAs: iatas,
|
||||
PayloadTypes: msg.Scope.PayloadTypes,
|
||||
ChannelHashes: msg.Scope.ChannelHashes,
|
||||
Events: msg.Scope.Events,
|
||||
RegionIATAs: regionIATAs,
|
||||
}
|
||||
subID := uuid.NewString()
|
||||
h.AddScope(client, subID, scope)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package ws
|
||||
|
||||
import "sync"
|
||||
|
||||
Reference in New Issue
Block a user