Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
07ae2c9
feat: package auto import logic
logaritmi Dec 19, 2025
c09a910
fix: lint
Dec 20, 2025
c084c93
chore: bump2version
Dec 20, 2025
75c8b6e
feat: missionpkg doesnt need to be in /proxy path
Dec 20, 2025
9f8436b
fix: use host url and protocol
sjlehtin Dec 20, 2025
9c1a57c
feat: missionpackage ephemeral MVP
Dec 21, 2025
51d5445
chore: bump
Dec 21, 2025
3e7ade3
chore: copy dialog.tsx from uiv2
sjlehtin Dec 21, 2025
2fea14c
feat: add react-dialog
sjlehtin Dec 21, 2025
6bfdf90
feat: use ephemeral tak mission package URL
sjlehtin Dec 21, 2025
408c3b6
fix: pre-commit gripes
sjlehtin Dec 21, 2025
332913f
feat: ephemeral wip links with a bit of crypto works now
Dec 21, 2025
f70cc6a
fix: use correct directory for certs
sjlehtin Dec 21, 2025
78c8130
fix: tweak auto import UI as per comments
sjlehtin Dec 26, 2025
bba654c
feat: take encrypted URL fragment into use
sjlehtin Jan 6, 2026
90ea077
chore: bump2version minor
sjlehtin Jan 6, 2026
6da4e5a
fix: return same error to user on decryption error as usual
sjlehtin Jan 6, 2026
add98b5
fix: generate tak package url encryption key on container start
sjlehtin Jan 6, 2026
66068ee
fix: tests should not pollute each other
sjlehtin Jan 6, 2026
6bd05b1
fix: workaround for clients not using content-disposition
sjlehtin Jan 7, 2026
d82525d
fix: atak.zip server.pref description to correct format.
Street3r Jan 7, 2026
f89bba2
chore: clean up old code, reduce code duplication
sjlehtin Jan 7, 2026
3f88030
feat: add deployment name to mission package name
sjlehtin Jan 7, 2026
2a31016
feat: add random nonce to the URL
sjlehtin Jan 7, 2026
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
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.7.3
current_version = 1.10.0
commit = False
tag = False

Expand Down
2 changes: 2 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ else
echo "No UI found at /ui_build, skipping copy."
fi

export TAKRMAPI_SECRET_KEY=${RMAPI_SECRET_KEY:-$(dd if=/dev/random bs=32 count=1 | base64 2>/dev/null)}

if [ "$#" -eq 0 ]; then
exec gunicorn "takrmapi.app:get_app()" --bind 0.0.0.0:8003 --forwarded-allow-ips='*' -w 4 -k uvicorn.workers.UvicornWorker
else
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "takrmapi"
version = "1.7.3"
version = "1.10.0"
description = "RASENMAEHER integration API for TAK server"
authors = ["Eero af Heurlin <rambo@iki.fi>", "Ari Karhunen <FIXME@example.com>"]
homepage = "https://github.com/pvarki/python-tak-rmapi"
Expand Down
2 changes: 1 addition & 1 deletion src/takrmapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""RASENMAEHER integration API for TAK server"""

__version__ = "1.7.3" # NOTE Use `bump2version --config-file patch` to bump versions correctly
__version__ = "1.10.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly
11 changes: 11 additions & 0 deletions src/takrmapi/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
from .description import router as description_router
from .instructions import router as instructions_router
from .tak_datapackage import router as takdatapackage_router
from .tak_missionpackage import router as takmissionpackage_router

from .description import router_v2 as description_router_v2
from .description import router_v2_admin as description_admin_router
from .userinfo import router as userinfo_router

from .tak_missionpackage import ephemeral_router as ephemeral_takmissionpackage_router

all_routers = APIRouter()
all_routers.include_router(testing_router, prefix="/users", tags=["users"]) # REMOVE ME
all_routers.include_router(usercrud_router, prefix="/users", tags=["users"])
Expand All @@ -24,8 +27,16 @@
all_routers.include_router(description_router, prefix="/description", tags=["description"])
all_routers.include_router(instructions_router, prefix="/instructions", tags=["instructions"])
all_routers.include_router(takdatapackage_router, prefix="/tak-datapackages", tags=["tak-datapackages"])
all_routers.include_router(takmissionpackage_router, prefix="/tak-missionpackages", tags=["tak-missionpackages"])


all_routers_v2 = APIRouter()
all_routers_v2.include_router(description_router_v2, prefix="/description", tags=["description"])
all_routers_v2.include_router(description_admin_router, prefix="/admin/description", tags=["description"])
all_routers_v2.include_router(userinfo_router, prefix="/clients", tags=["clients"])


all_routers_ephemeral_v1 = APIRouter()
all_routers_ephemeral_v1.include_router(
ephemeral_takmissionpackage_router, prefix="/tak-missionpackages", tags=["ephemeral-tak-missionpackages"]
)
203 changes: 203 additions & 0 deletions src/takrmapi/api/tak_missionpackage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Endpoint to deliver user missionpackages from templates/tak_missionpackage"""

from pathlib import Path
import logging
import base64
import binascii
import urllib.parse
import time
import os
import json
import secrets
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from libpvarki.middleware.mtlsheader import MTLSHeader
from libpvarki.schemas.product import UserCRUDRequest

from takrmapi import config
from takrmapi.takutils import tak_helpers
from takrmapi.takutils.tak_pkg_helpers import TAKDataPackage, TAKPackageZip

LOGGER = logging.getLogger(__name__)

router = APIRouter(dependencies=[Depends(MTLSHeader(auto_error=True))])

ephemeral_router = APIRouter()


def hash_from_str(hash_from: str) -> str:
"""Return checksum string from given string"""
ct_digest = hashes.Hash(hashes.SHA256())
ct_digest.update(hash_from.encode("ascii"))
ct_dig = ct_digest.finalize()
try:
ct_dig_str = binascii.hexlify(ct_dig).decode()
except binascii.Error as e:
LOGGER.info("Unable to convert digest bin to string. Possible malformed query.")
LOGGER.debug(e)
return "err"

return ct_dig_str


@router.post("/client-zip/{variant}.zip")
async def return_tak_zip(user: UserCRUDRequest, variant: str, background_tasks: BackgroundTasks) -> FileResponse:
"""Return TAK client zip file proxied from rm api"""

localuser = tak_helpers.UserCRUD(user)
if not localuser:
raise HTTPException(status_code=404, detail="User data not found")

target_pkg = await create_mission_package(localuser, variant, background_tasks)

return FileResponse(
path=target_pkg.zip_path,
media_type="application/zip",
filename=f"{localuser.callsign}_{variant}.zip",
)


async def create_mission_package(
localuser: tak_helpers.UserCRUD, variant: str, background_tasks: BackgroundTasks
) -> TAKDataPackage:
"""Create mission package from template"""
walk_dir = Path(config.TAK_MISSIONPKG_TEMPLATES_FOLDER) / "default" / variant

if not walk_dir.is_dir():
raise HTTPException(status_code=404, detail=f"Variant '{variant}' not found")

tak_missionpkg = TAKPackageZip(localuser)
target_pkg = TAKDataPackage(template_path=walk_dir, template_type="mission")
await tak_missionpkg.create_zip_bundles(datapackages=[target_pkg])

if not target_pkg.zip_path or not target_pkg.zip_path.is_file():
raise HTTPException(status_code=500, detail="Failed to generate ZIP")

background_tasks.add_task(tak_missionpkg.helpers.remove_tmp_dir, target_pkg.zip_tmp_folder)

return target_pkg


@router.post("/ephemeral/{variant}.zip")
async def return_ephemeral_dl_link(user: UserCRUDRequest, variant: str) -> dict[str, str]:
"""Return an ephemeral download link to get the TAK client zip file"""
localuser = tak_helpers.UserCRUD(user)
if not localuser:
raise HTTPException(status_code=404, detail="User data not found")
request_time = int(time.time())

encrypted_url = generate_encrypted_ephemeral_url_fragment(user.callsign, user.uuid, variant, request_time)

# ATAK seems to take offense if the package file name is the same between
# requests, so we add a random token to the filename.
nonce = secrets.token_hex(8)

ephemeral_url = (
f"https://{config.read_tak_fqdn()}:{config.PRODUCT_HTTPS_EPHEMERAL_PORT}/"
f"ephemeral/api/v1/tak-missionpackages/ephemeral/"
f"{urllib.parse.quote_plus(encrypted_url)}/{config.read_deployment_name()}_{nonce}_{variant}.zip"
)

LOGGER.info("Returning ephemeral url: %s", ephemeral_url)

return {"ephemeral_url": ephemeral_url}


@ephemeral_router.get("/ephemeral/{ephemeral_str}/{zipfile_name}.zip")
async def return_ephemeral_tak_zip(ephemeral_str: str, background_tasks: BackgroundTasks) -> FileResponse:
"""Return the TAK client zip file using an ephemeral link"""
LOGGER.info("Got ephemeral url fragment: %s", ephemeral_str)

callsign, user_uuid, variant = parse_encrypted_ephemeral_url_fragment(ephemeral_str)

localuser: tak_helpers.UserCRUD = tak_helpers.UserCRUD(
UserCRUDRequest(uuid=user_uuid, callsign=callsign, x509cert="")
)
if not localuser:
raise HTTPException(status_code=404, detail="User data not found")

target_pkg = await create_mission_package(localuser, variant, background_tasks)

return FileResponse(
path=target_pkg.zip_path,
media_type="application/zip",
filename=f"{localuser.callsign}_{config.read_deployment_name()}_{variant}.zip",
)


def generate_encrypted_ephemeral_url_fragment(
user_callsign: str, user_uuid: str, variant: str, request_time: float
) -> str:
"""Return encrypted ephemeral url"""
plaintext_str: str = json.dumps({"callsign": user_callsign, "uuid": user_uuid, "variant": variant})
iv = os.urandom(12)
encryptor = Cipher(algorithms.AES(TAKDataPackage.get_ephemeral_byteskey()), modes.GCM(iv)).encryptor()
user_payload: bytes = encryptor.update(plaintext_str.encode("ascii")) + encryptor.finalize()
user_payload_b64: str = base64.b64encode(user_payload).decode("ascii")
iv_b64: str = base64.b64encode(iv).decode("ascii")
payload_digest: str = hash_from_str(user_payload_b64 + iv_b64 + str(request_time))
encode: str = base64.b64encode(
json.dumps(
{
"payload_b64": user_payload_b64,
"iv_b64": iv_b64,
"request_time": request_time,
"digest": payload_digest,
}
).encode("ascii")
).decode("ascii")

return encode


def parse_encrypted_ephemeral_url_fragment(ephemeral_str: str) -> tuple[str, str, str]:
"""Parse and decrypt ephemeral url and return callsign, user uuid and variant.

Verify that the ephemeral link is not expired and that the checksum matches.
"""
try:
e_decoded = base64.b64decode(ephemeral_str.encode("ascii")).decode("ascii")
except binascii.Error as exc:
LOGGER.info("Unable to decode base64 to string. Possible malformed query.")
LOGGER.debug(exc)
raise HTTPException(status_code=404, detail="User data not found") from exc

ephemeral_json = json.loads(e_decoded)
if ephemeral_json["request_time"] + 300 < int(time.time()):
LOGGER.info("Ephemeral link has expired.")
raise HTTPException(status_code=404, detail="User data not found")

# TODO: probably should be a keyed hash to catch attempts to pass an incorrect link early
payload_digest: str = hash_from_str(
ephemeral_json["payload_b64"] + ephemeral_json["iv_b64"] + str(ephemeral_json["request_time"])
)
if payload_digest != ephemeral_json["digest"]:
LOGGER.info(
"Checksum mismatch. Calculated: {}, but got from response: {}".format(
payload_digest, ephemeral_json["digest"]
)
)
raise HTTPException(status_code=404, detail="User data not found")

iv: bytes = base64.b64decode(ephemeral_json["iv_b64"].encode("ascii"))
user_payload: bytes = base64.b64decode(ephemeral_json["payload_b64"].encode("ascii"))

decryptor = Cipher(algorithms.AES(TAKDataPackage.get_ephemeral_byteskey()), modes.GCM(iv)).encryptor()
decrypted_payload = decryptor.update(user_payload) + decryptor.finalize()

try:
decrypted_json = json.loads(decrypted_payload.decode("ascii"))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
LOGGER.exception("Decryption failure. It could be someone is trying something nasty.")
raise HTTPException(status_code=404, detail="User data not found") from exc

variant = decrypted_json["variant"]
callsign = decrypted_json["callsign"]
user_uuid = decrypted_json["uuid"]

LOGGER.debug("Got the following data in ephemeral user payload: {}".format(decrypted_json))

return callsign, user_uuid, variant
3 changes: 2 additions & 1 deletion src/takrmapi/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from takrmapi import config
from takrmapi.takutils import tak_init
from .config import LOG_LEVEL
from .api import all_routers, all_routers_v2
from .api import all_routers, all_routers_v2, all_routers_ephemeral_v1


LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -57,6 +57,7 @@ def get_app_no_init() -> FastAPI:
app = FastAPI(docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=app_lifespan, version=__version__)
app.include_router(router=all_routers, prefix="/api/v1")
app.include_router(router=all_routers_v2, prefix="/api/v2")
app.include_router(router=all_routers_ephemeral_v1, prefix="/ephemeral/api/v1")
return app


Expand Down
2 changes: 2 additions & 0 deletions src/takrmapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ def read_deployment_name() -> str:
TAK_CERTS_FOLDER: Path = cfg("TAK_CERTS_FOLDER", cast=Path, default=Path("/opt/tak/data/certs/files"))
RMAPI_PERSISTENT_FOLDER: Path = cfg("RMAPI_PERSISTENT_FOLDER", cast=Path, default=Path("/data/persistent"))

PRODUCT_HTTPS_EPHEMERAL_PORT: int = cfg("PRODUCT_HTTPS_EPHEMERAL_PORT", cast=int, default=4627)

# TAK vite asset graphical addons
VITE_ASSET_SET: str = cfg("VITE_ASSET_SET", cast=str, default="not_used_by_default")
VITE_ASSET_SET_TEMPLATES_FOLDER: Path = cfg(
Expand Down
51 changes: 41 additions & 10 deletions src/takrmapi/takutils/tak_pkg_helpers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Helper functions to manage tak data packages"""

from typing import List, Any, Dict
import base64
import binascii
from typing import List, Any, Dict, ClassVar
import logging
from dataclasses import dataclass, field
from pathlib import Path
from glob import glob
import tempfile
import asyncio
import os
Expand Down Expand Up @@ -35,6 +36,23 @@ class TAKPkgVars:
template_file_render_str: str


def _get_secret_key_from_environ() -> bytes:
"""Fetch the secret key from the environment variable."""

from_env = os.environ.get("TAKRMAPI_SECRET_KEY", "")
try:
key_candidate = base64.b64decode(from_env.encode("ascii"))
except (TypeError, binascii.Error):
LOGGER.warning("TAKRMAPI_SECRET_KEY is not valid base64 encoded string.")
return b""

if len(key_candidate) >= 32:
return key_candidate[:32]

LOGGER.warning("TAKRMAPI_SECRET_KEY is too short. It should be at least 32 bytes long.")
return b""


@dataclass
class TAKDataPackage:
"""TAK Datapackage helper"""
Expand All @@ -43,6 +61,7 @@ class TAKDataPackage:
template_type: str

_pkgvars: TAKPkgVars = field(init=False)
ephemeral_key: ClassVar[bytes] = b""

# TODO savolaiset muuttujat
# _zip_path: Path = field(init=False)
Expand Down Expand Up @@ -79,6 +98,16 @@ def __post_init__(self) -> None:
template_file_render_str="",
)

@classmethod
def get_ephemeral_byteskey(cls) -> bytes:
"""Return key for ephemeral file requests"""
if not cls.ephemeral_key:
cls.ephemeral_key = _get_secret_key_from_environ()
if not cls.ephemeral_key:
raise RuntimeError("Ephemeral key not set!")

return cls.ephemeral_key

@property
def is_folder(self) -> bool:
"""Check if package is folder"""
Expand Down Expand Up @@ -348,33 +377,35 @@ async def zip_folder_content(self, zipfile: str, tmp_folder: str) -> None:
await asyncio.get_running_loop().run_in_executor(None, shutil.make_archive, zipfile, "zip", tmp_folder)

async def tak_missionpackage_extras(self, manifest_file: Path, tmp_folder: Path) -> None:
"""Check if there is some extra that needs to be done defined in manfiest"""
"""Check if there is some extra that needs to be done defined in the manifest"""
manifest_rows = manifest_file.read_text().splitlines()
for row in manifest_rows:
# .p12 rows
# <!--
if "<!--" in row:
continue

# Add certificate file to zip package
# Add the certificate files to the zip package
if ".p12" in row:
await self.tak_missionpackage_add_p12(row, tmp_folder)

async def tak_missionpackage_add_p12(self, row: str, tmp_folder: Path) -> None:
"""Handle manifest .p12 rows"""
tmp_folder = await self.chk_manifest_file_extra_folder(row=row, tmp_folder=tmp_folder)
# FIXME: do the blocking IO in executor
LOGGER.info("PKCS12 Got template %s, tmp folder %s...", row, tmp_folder)
if "rasenmaeher_ca-public.p12" in row:
# FIXME: instead of adding the root key into the software, need a way to get full chain with Root CA
templates_folder = Path(__file__).parent / "templates"
ca_files = sorted(glob(f"{templates_folder}/*.pem"))
templates_folder = Path(__file__).parent.parent / "templates"
LOGGER.info("Searching keys from %s...", templates_folder)
ca_files = templates_folder.rglob("*.pem")
srcdata = Path("/le_certs/rasenmaeher/fullchain.pem").read_bytes()
if ca_files:
for ca_f in ca_files:
srcdata = srcdata + Path(ca_f).read_bytes()
for ca_f in sorted(ca_files):
LOGGER.info("Adding PEM %s to CA bundle", ca_f.name)
srcdata = srcdata + ca_f.read_bytes()

tgtfile = Path(tmp_folder) / "rasenmaeher_ca-public.p12"
LOGGER.info("Creating {}".format(tgtfile))
LOGGER.info("Creating %s", tgtfile)
p12bytes = convert_pem_to_pkcs12(srcdata, None, "public", None, "ca-chains")
tgtfile.parent.mkdir(parents=True, exist_ok=True)
LOGGER.debug("{} exists: {}".format(tgtfile.parent, tgtfile.parent.exists()))
Expand Down
Loading