Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Runtime state and secrets generated by OpenTAKServer on first boot.
# None of this belongs in version control — the tracked content under ots/ is
# limited to configs/ and mediamtx/ templates.

# PKI: CA signing key, issued client keys, .p12 bundles, CRL state.
# ca-do-not-share.key signs every cert the server trusts.
# Note the trailing /* rather than /: excluding the directory itself would make
# the !negation below impossible to honour.
/ots/ca/*
!/ots/ca/.gitignore

# Postgres data directory
/ots/pgdata/

# Logs, uploads, and generated runtime config (holds broker credentials)
/ots/logs/
/ots/uploads/
/ots/config.yml
/ots/icons.sqlite

# Certbot / Let's Encrypt material
/ots/configs/nginx/letsencrypt/

# macOS
.DS_Store

ots_config.env

# Symlink to ots_config.env so compose can interpolate ${OTS_DB_PORT}.
.env
11 changes: 9 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@ RUN chown -R ots:ots /app
RUN python -m venv /app/venv
ENV PATH="/app/venv/bin:$PATH"

# TODO: Install from PyPI
RUN pip install git+https://github.com/brian7704/OpenTAKServer.git
# Pinned to a tagged release. Unpinned git HEAD ships mid-development
# snapshots; 1.7.11 is the highest version with matching ghcr handler images.
RUN pip install opentakserver==1.7.11

# Fix two upstream 1.7.11 bugs that stop CoT reaching clients. See the script
# for details; it fails the build if either anchor is missing, so a version bump
# cannot silently drop a patch.
COPY --chown=ots:ots patches/fix_eud_handler.py /tmp/fix_eud_handler.py
RUN python3 /tmp/fix_eud_handler.py

RUN /app/venv/bin/flask --app /app/venv/lib/python3.13/site-packages/opentakserver/app.py ots create-ca
#RUN /app/venv/bin/flask --app /app/venv/lib/python3.13/site-packages/opentakserver/app.py db upgrade
Expand Down
103 changes: 98 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,101 @@
# OpenTAKServer-Docker

This repo contains the docker-compose.yml and config files needed to run OpenTAKServer in a docker container.
To use it, install docker on your system, clone this repo, and run `docker compose up -d` or `sudo docker compose up -d`
depending on your platform.
This repo contains the `docker-compose.yml` and config files needed to run OpenTAKServer in a
docker container.

This repo is new and not quite complete. Full documentation will be available on https://docs.opentakserver.io when it's ready
for use. CloudTAK will also be incorporated into docker-compose.yml.
This repo is new and not quite complete. Full documentation will be available at
https://docs.opentakserver.io when it's ready for use. CloudTAK will also be incorporated into
`docker-compose.yml`.

## Table of contents

- [Getting started](#getting-started)
- [Configuration](#configuration)
- [Host ports](#host-ports)
- [Scripts](#scripts)
- [Usage with Colata API](#usage-with-colata-api)

## Getting started

To use it, install docker on your system, clone this repo.

Create config file:

```sh
cp ots_config.env.template ots_config.env
```

In `ots_config.env`:

```diff
+ OTS_FQDN=<your MacOS local network IP>
+ OTS_MEDIAMTX_TOKEN=<random, alphanumeric string>
```

For `OTS_FQDN` set server's host IP:

```
ipconfig getifaddr en0
```

The `.env` symlink is required. `env_file:` in `docker-compose.yml` passes
`ots_config.env` *into the containers*, but Compose resolves `${...}` placeholders in the
compose file itself only from the shell environment or a `.env` file. Without the symlink
the port variables below are silently ignored and those host ports fall back to their
defaults.

```sh
ln -s ots_config.env .env
```

Run `docker compose up -d` (or `sudo docker compose up -d`, depending on your platform).

A successful run opens the TAK admin panel at `http://localhost:80`.

## Configuration

### Host ports

Every published host port is configurable, so this stack can coexist with other local
projects. In each case only the **host** side moves; the container side is fixed.

| Variable | Default | Published for |
| --- | --- | --- |
| `OTS_DB_PORT` | `5433` | Postgres |
| `OTS_WEB_HTTP_PORT` | `80` | HTTP Web UI |
| `OTS_WEB_HTTPS_PORT` | `443` | HTTPS Web UI |
| `OTS_API_HTTP_PORT` | `18080` | HTTP API (nginx-proxy) |
| `OTS_API_HTTPS_PORT` | `8443` | HTTPS API (nginx-proxy) |
| `OTS_API_INTERNAL_PORT` | `8081` | Internal Flask API server |
| `OTS_CERT_ENROLLMENT_PORT` | `8446` | Certificate enrollment |
| `OTS_MQTT_PORT` | `8883` | MQTT / Meshtastic |

`OTS_DB_PORT`'s container side stays 5432 because OTS reaches its database via
`SQLALCHEMY_DATABASE_URI=...@ots-db/ots`, which carries no port and resolves over the
compose network.

The `nginx-proxy` container sides are fixed because each is a hardcoded `listen` directive
in `ots/configs/nginx/templates` — remap the host side and nginx still listens where it
always did, but change the container side and nothing is listening there at all.

Two caveats when moving the web ports:

- Clients must then include the port explicitly. `markers.sh` and anything else calling
`https://<host>/api/...` assumes `443`.
- `OTS_FQDN` carries no port, so certificate enrollment and any TAK client profile deriving
URLs from it still expect the defaults.

## Scripts

To manage markers manually use the `bin/markers.sh` or `bin/remove_all_markers.sh` script. See docs in
script comments.

## Usage with Colata API

This setup was created to work specially with [Colata API](https://github.com/codequest-eu/drone-detection-api).

1. Run TAK server before the API to receive initial detector events.
2. Make sure the TAK client (like Android's `ATAK`) is properly connected to the TAK server via
TCP (`8080` port) using the IP provided in `ots_config.env`. ⚠️ The TAK server and client have
to be on the same network for this to work.
3. Run the API with the `atak-worker` service.
175 changes: 175 additions & 0 deletions bin/markers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
#
# Create a marker on the OpenTAKServer map via POST /api/markers.
# The marker is stored in the DB and broadcast to connected TAK clients.
#
# ------------------------------------------------------------------------------
# EXAMPLES
# ------------------------------------------------------------------------------
#
# Create an unknown-ground marker (type defaults to a-u-G):
# ./markers.sh -n "Waypoint 1" -a 52.2297 -o 21.0122
#
# Create a friendly marker (blue icon on the client):
# ./markers.sh -n "OP North" -a 52.2297 -o 21.0122 -t a-f-G-U-C
#
# Create a hostile marker (red icon):
# ./markers.sh -n "Contact" -a 52.3100 -o 21.1100 -t a-h-G
#
# List every marker currently on the map (paginated, 10 per page):
# ./markers.sh -l
#
# Move or rename an existing marker by reusing its UID. This updates the
# marker in place rather than creating a duplicate:
# ./markers.sh -n "OP North" -a 52.4000 -o 21.0122 -t a-f-G-U-C \
# -u 10e99852-26f2-454f-9acb-86da78f46cf9
#
# Delete a marker:
# ./markers.sh -d 10e99852-26f2-454f-9acb-86da78f46cf9
#
# Target a different server, or use different credentials, without editing
# this file:
# OTS_HOST=10.0.0.5 ./markers.sh -n Rally -a 52.1 -o 21.0
# OTS_USER=alice OTS_PASS=hunter2 ./markers.sh -n Rally -a 52.1 -o 21.0
#
# Drop a row of markers from a loop:
# for i in 1 2 3; do
# ./markers.sh -n "Checkpoint $i" -a "52.2$i" -o 21.01
# done
#
# Print the full option reference:
# ./markers.sh -h
#
# ------------------------------------------------------------------------------
# NOTES
# ------------------------------------------------------------------------------
#
# - UIDs must be UUID4; the script generates one unless you pass -u.
# - Markers go stale on clients 24h after creation (server-side default).
# - A client must be connected when the marker is created to receive it;
# replay-on-connect for pre-existing markers is untested.
#
set -euo pipefail

OTS_HOST="${OTS_HOST:-$(ipconfig getifaddr en0)}"
OTS_USER="${OTS_USER:-administrator}"
OTS_PASS="${OTS_PASS:-password}"

usage() {
cat <<EOF
Usage: $(basename "$0") -n NAME -a LAT -o LON [-t TYPE] [-u UUID]

Required:
-n NAME Callsign shown on the map
-a LAT Latitude (-90..90)
-o LON Longitude (-180..180)

Optional:
-t TYPE CoT type (default: a-u-G)
a-f-G-U-C friendly ground infantry (blue)
a-h-G hostile ground (red)
a-n-G neutral ground (green)
a-u-G unknown ground (yellow)
-u UUID Marker UID, must be UUID4. Generated if omitted.
Reusing an existing UID updates that marker.
-l List current markers and exit
-d UUID Delete the marker with this UID and exit

Environment:
OTS_HOST server address (default: $OTS_HOST)
OTS_USER username (default: $OTS_USER)
OTS_PASS password (default: password)

Examples:
$(basename "$0") -n "OP North" -a 52.2297 -o 21.0122 -t a-f-G-U-C
OTS_HOST=10.0.0.5 $(basename "$0") -n Rally -a 52.1 -o 21.0
EOF
exit "${1:-1}"
}

name="" lat="" lon="" cot_type="a-u-G" muid="" action="add" del_uid=""

while getopts ":n:a:o:t:u:d:lh" opt; do
case "$opt" in
n) name="$OPTARG" ;;
a) lat="$OPTARG" ;;
o) lon="$OPTARG" ;;
t) cot_type="$OPTARG" ;;
u) muid="$OPTARG" ;;
l) action="list" ;;
d) action="delete"; del_uid="$OPTARG" ;;
h) usage 0 ;;
:) echo "Error: -$OPTARG requires an argument" >&2; usage ;;
\?) echo "Error: unknown option -$OPTARG" >&2; usage ;;
esac
done

# Log in and grab a bearer token. Flask-Security returns it under
# response.user.authentication_token when include_auth_token is set.
login() {
local body
body=$(curl -sk --fail-with-body \
-X POST "https://${OTS_HOST}/api/login?include_auth_token" \
-H "Content-Type: application/json" \
-d "{\"username\":\"${OTS_USER}\",\"password\":\"${OTS_PASS}\"}") || {
echo "Error: login failed for ${OTS_USER}@${OTS_HOST}" >&2
exit 1
}
python3 -c "
import json, sys
try:
print(json.load(sys.stdin)['response']['user']['authentication_token'])
except (KeyError, ValueError):
sys.exit('Error: no auth token in login response')
" <<<"$body"
}

token=$(login)

if [[ "$action" == "list" ]]; then
curl -sk "https://${OTS_HOST}/api/markers" \
-H "Authentication-Token: ${token}" | python3 -m json.tool
exit 0
fi

if [[ "$action" == "delete" ]]; then
curl -sk -X DELETE "https://${OTS_HOST}/api/markers?uid=${del_uid}" \
-H "Authentication-Token: ${token}"
echo
exit 0
fi

[[ -n "$name" && -n "$lat" && -n "$lon" ]] || {
echo "Error: -n, -a and -o are required" >&2
usage
}

# The API rejects anything that is not UUID4, so generate rather than improvise.
if [[ -z "$muid" ]]; then
muid=$(python3 -c "import uuid; print(uuid.uuid4())")
fi

payload=$(python3 -c "
import json, sys
print(json.dumps({
'uid': sys.argv[1],
'name': sys.argv[2],
'latitude': float(sys.argv[3]),
'longitude': float(sys.argv[4]),
'type': sys.argv[5],
'fov': 90
}))
" "$muid" "$name" "$lat" "$lon" "$cot_type")

response=$(curl -sk -X POST "https://${OTS_HOST}/api/markers" \
-H "Content-Type: application/json" \
-H "Authentication-Token: ${token}" \
-d "$payload")

if [[ "$response" == *'"success":true'* ]]; then
echo "Created '${name}' (${cot_type}) at ${lat},${lon}"
echo " uid: ${muid}"
else
echo "Failed: ${response}" >&2
exit 1
fi
55 changes: 55 additions & 0 deletions bin/remove_all_markers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/sh
#
# Remove all markers from the OpenTAKServer map
#

set -eu

OTS_HOST="${OTS_HOST:-$(ipconfig getifaddr en0)}"
OTS_USER="${OTS_USER:-administrator}"
OTS_PASS="${OTS_PASS:-password}"

# Get script directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

# List all markers and extract UIDs, then delete each one
echo "Fetching markers from ${OTS_HOST}..."

marker_uids=$(OTS_HOST="${OTS_HOST}" OTS_USER="${OTS_USER}" OTS_PASS="${OTS_PASS}" "$SCRIPT_DIR/markers.sh" -l 2>/dev/null | python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
# Try both possible response formats
markers = data.get('response', {}).get('markers', []) or data.get('results', [])
for m in markers:
print(m.get('uid'))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
")

if [ -z "$marker_uids" ]; then
echo "No markers found on the map."
exit 0
fi

count=$(echo "$marker_uids" | wc -l)
echo "Found $count item(s). Attempting deletion..."

deleted=0
failed=0

echo "$marker_uids" | while IFS= read -r uid; do
if [ -n "$uid" ]; then
result=$(OTS_HOST="${OTS_HOST}" OTS_USER="${OTS_USER}" OTS_PASS="${OTS_PASS}" "$SCRIPT_DIR/markers.sh" -d "$uid" 2>&1)
if echo "$result" | grep -q '"success":true'; then
echo "✓ Deleted: $uid"
deleted=$((deleted + 1))
else
failed=$((failed + 1))
fi
fi
done

echo ""
echo "Done! Markers have been deleted."
Loading