Continuously Operating Reference Station Hub: Python-based NTRIP (V2) caster for aggregating RTK corrections from a network of CORS base stations and distributing them to NTRIP clients.
The network is open. Anyone can connect a rover to receive RTK corrections, or contribute a base station to extend coverage.
https://corshub.peinser.com
You can connect a rover without registering using the credentials anonymous / anonymous. Anonymous access is limited:
- one concurrent connection
- all base-stations (mountpoint
*), no pinning to a specific station - sessions are time-limited (1 minute)
This is intended for evaluating the network. For production use, register a rover to get a persistent identity, unlimited sessions, and the ability to pin to a specific mountpoint.
Access is managed through a pull request to this repository. You add your entry to ops/values.yaml, open the PR, and a bot generates your credentials automatically. The password is encrypted with your SSH public key from GitHub and posted as a comment on the PR. Only you can decrypt it.
You do not need to be a collaborator. Fork the repository, make your change, and open the PR from your fork.
No password_hash or github_user field is required in your entry. The bot fills in the hash; your GitHub identity is taken from whoever opens the PR.
The PR is closed automatically by the bot after credentials are issued. Your entry is committed directly to main by the bot, not by merging your PR. This is intentional: it ensures only the specific entry you added lands on main, with no risk of other files from your fork being merged in.
A rover receives RTK corrections from any registered base station. Use the mountpoint * to accept corrections from the nearest available base station, or specify a mountpoint name to pin to a specific one.
Add your entry under opa.registry.rovers:
opa:
registry:
rovers:
your-chosen-username:
mountpoints: ["*"]
valid_from: "YYYY-MM-DD"
valid_until: "YYYY-MM-DD"Replace your-chosen-username with whatever NTRIP username you want to use. Set valid_until to when you expect to stop using the service (you can always rotate or extend with another PR).
To pin to a specific base station instead of any:
mountpoints: ["MOUNTPOINT_NAME"]See the available mountpoints section below.
The bot runs automatically when your PR is opened. It validates the PR, issues a credential, commits your entry directly to main, posts your encrypted password as a comment, and then closes the PR. You do not need to wait for a maintainer and you should not try to merge the PR yourself.
Install age if you do not already have it:
# macOS
brew install age
# Linux
apt install age # or download from https://github.com/FiloSottile/age/releasesThe PR comment contains a ready-to-run command with the encrypted block already inlined. Copy and run it, replacing ~/.ssh/your-private-key with the private key matching one of the fingerprints listed in the comment:
age -d -i ~/.ssh/your-private-key <<'EOF'
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
EOF| Setting | Value |
|---|---|
| Host | corshub.peinser.com |
| Port | 443 |
| TLS | yes |
| Mountpoint | the mountpoint(s) you listed, or any available one |
| Username | the username you chose in ops/values.yaml |
| Password | the decrypted password from step 4 |
Store the password securely. It will not be shown again. To get a new one, see rotating credentials below.
A base station streams RTCM corrections from a fixed antenna to the caster. Other users' rovers can then use your corrections for RTK positioning. The practical range of a single base station is roughly 30-50 km.
The mountpoint identifies your base station on the caster. Pick something descriptive, for example BRUSSELS-0 or GHENT-ROOFTOP. It must be unique across all registered base stations.
Add your entry under opa.registry.base_stations:
opa:
registry:
base_stations:
your-chosen-username:
mountpoint: YOUR_MOUNTPOINT
valid_from: "YYYY-MM-DD"
valid_until: "YYYY-MM-DD"The username field (the map key) is the NTRIP username your base station software will use to authenticate when pushing corrections. The mountpoint field is the name rovers will see and connect to.
Follow steps 2-4 from the rover guide above. The process is identical.
The tools/here4-base-caster.py script handles this automatically for (Here4) u-blox receivers. See the Tools section for full usage. For other receivers, configure your NTRIP client in push mode:
| Setting | Value |
|---|---|
| Host | corshub.peinser.com |
| Port | 443 |
| TLS | yes |
| Mountpoint | the mountpoint you registered |
| Username | the username you chose |
| Password | the decrypted password |
The connection uses HTTP PUT (NTRIP v2). Ensure your client supports NTRIP v2 and is not buffering the stream.
To get a new password for an existing entry, open a PR that removes only the password_hash field from your entry in ops/values.yaml. Leave everything else unchanged.
The bot verifies that the PR is opened by the same GitHub account that originally registered the entry, then issues a new credential. The old password stops working as soon as the PR is merged and the deployment rolls out.
The list of active base stations and their approximate locations can be found in ops/values.yaml under opa.registry.base_stations. The mountpoint field of each entry is the name to use in your NTRIP client.
The caster continuously monitors each base station stream for signals of GNSS spoofing:
- Antenna position drift Every RTCM 1005/1006 message contains the base station's ECEF coordinates. A fixed installation must report an identical position on every message. Any deviation larger than 1 cm from the first observed position is counted and surfaced in the Grafana dashboard.
- Abnormally high signal strength Spoofed transmissions are typically stronger than natural open-sky signals. CNR observations above 55 dBHz per satellite are tracked separately and shown as a fraction of total CNR observations per constellation.
These metrics are available in the Grafana dashboard under the "Spoofing Indicators" section. Neither indicator is conclusive on its own, but a simultaneous ARP position change and elevated CNR across multiple constellations is a strong combined signal.
The thresholds are configurable via the Helm chart values gnss.arpChangeThresholdMeters and gnss.highCnrThresholdDbhz.
- Joining the Network
- Signal Integrity Monitoring
- Tools
- Prerequisites
- Getting Started
- Development Workflow
- Running Tests
- Code Quality
- Documentation
- Docker
- CI/CD
- Contributing
A terminal tool that turns a Here4 u-blox receiver into a live NTRIP v2 base station. It handles the full lifecycle automatically: device discovery, initial configuration, survey-in, and streaming RTCM corrections to a CORSHub caster.
| Phase | What happens |
|---|---|
| Searching | Scans serial ports for a u-blox device (USB VID 0x1546 or /dev/ttyACM*). |
| Connecting | Opens the port at 115 200 baud and enables NAV-PVT, NAV-SAT, and NAV-SVIN messages at 1 Hz. |
| Monitoring | Streams live position, satellite C/N0, and accuracy data. Waits for a valid 3D GNSS fix. |
| Survey-In | Starts survey-in (CFG-TMODE3). Accumulates observations until the mean position accuracy drops below 2 m for at least 60 s. Enables RTCM 3.3 output messages in parallel (1005, 1074, 1084, 1094, 1124, 1230). |
| Fixed | Streams every RTCM correction frame from the serial port to the configured CORSHub mountpoint over NTRIP v2 HTTP PUT. |
The live display refreshes at 2 Hz and shows position, velocity, pDOP, UTC time, survey-in progress, RTCM output statistics, NTRIP caster push status, and a per-satellite C/N0 table.
# Survey-in mode (automatic position estimation):
python tools/here4-base-caster.py \
--caster-url https://corshub.peinser.com \
--mountpoint HERE4 \
--username HERE4 \
--password <password>
# Fixed mode (known surveyed position, best absolute accuracy):
python tools/here4-base-caster.py \
--lat 50.85034 --lon 4.35171 --alt 65.4 \
--caster-url https://corshub.peinser.com \
--mountpoint HERE4 \
--username HERE4 \
--password <password>Survey-in vs. fixed mode: Survey-in gives ~2 m absolute base accuracy, which translates to ~2 m absolute rover accuracy (RTK relative accuracy is always centimetre-level regardless). For sub-metre absolute accuracy, place the antenna on a surveyed mark and supply
--lat,--lon,--alt.
All required packages are included in the project's main dependency set (aiohttp, pyubx2, pyrtcm, pyserial, rich). No separate install step is needed if the project virtualenv is active.
For Option A (Dev Container):
- Docker (Desktop or Engine)
- VS Code with the Dev Containers extension
For Option B (local):
- Python 3.12 or newer
- uv
-
Clone the repository:
git clone git@github.com:peinser/corshub.git cd corshub -
Open in VS Code and reopen in container:
Ctrl+Shift+P → Dev Containers: Reopen in ContainerVS Code will build the container image and run the post-creation script, which installs all dependencies automatically via
uv sync --locked. -
Verify the setup:
make help
-
Clone the repository:
git clone git@github.com:peinser/corshub.git cd corshub -
Install uv (if not already installed):
curl -LsSf https://astral.sh/uv/install.sh | sh -
Install dependencies:
make setup
-
Verify the setup:
make help
All common tasks are available through make. Run make help to see the full list.
| Command | Description |
|---|---|
make setup |
Install all dependencies (first-time setup) |
make sync |
Re-sync dependencies after editing pyproject.toml |
make lock |
Update uv.lock after adding or removing dependencies |
make format |
Auto-format code with Ruff |
make lint |
Run Ruff (linter) and MyPy (type checker) |
make test |
Run the test suite with coverage |
make clean |
Remove build artefacts and caches |
make all |
Full local CI pipeline: clean → install → lint → test |
uv add <package> # Runtime dependency
uv add --dev <package> # Development-only dependency
make lock # Update uv.lockmake testThis runs pytest with branch coverage enabled. A minimum of 75% coverage is required. To view a detailed HTML report:
uv run pytest --cov=src --cov-report=html
open htmlcov/index.htmlTests requiring async support use pytest-asyncio. Mark async test functions with @pytest.mark.asyncio.
make formatmake lintRuns two checks in sequence:
- Ruff - covers flake8, isort, pyupgrade, bugbear, and more.
- MyPy - strict type checking across
src/corshub/andtests/.
uv run bandit -r src/| Stage | Purpose |
|---|---|
builder-base |
Installs locked dependencies (no dev extras) |
validate |
Runs format check, Ruff, MyPy, pytest, and Bandit |
production |
Minimal runtime image; runs as a non-root user (UID 1001) |
# Run only the validation stage
docker build --target validate -f docker/Dockerfile .
# Build the final production image
docker build -f docker/Dockerfile -t corshub:local .
# Run
docker run --rm corshub:localSee CONTRIBUTING.md for the full contribution guide.
Thanks to SEMU Consulting for their excellent GEO Python libraries.
