A Local TAK Server from One Script

One bash script that stands up TAK Server 5.8 in Docker, and the three release bugs it works around

What it is

TAK (Team Awareness Kit) is a family of map-based situational awareness apps: ATAK on Android, iTAK on iOS, WinTAK on Windows. TAK Server sits in the middle. Clients connect to it over TLS, it relays their Cursor on Target (CoT) messages to each other, and it adds a web client (WebTAK), an admin console, and certificate enrollment.

install-tak.sh is a single bash script that takes the hardened Docker release of TAK Server 5.8 from a zip file to a running server on one host, with its own self-signed CA.

mkdir tak && cd tak
cp /path/to/takserver-docker-hardened-5.8-RELEASE-69.zip .
cp /path/to/install-tak.sh .
./install-tak.sh
The release zip comes from tak.gov and needs an account there. The script does not download it for you.

The first run takes 15-30 minutes (two ~700 MB build contexts, base image pulls, a Postgres initdb during the build, then five JVMs starting). After that the Docker layer cache does most of the work and a run takes a few minutes.

Why

I wanted a TAK Server on my own machine to develop against. Three things got in the way.

Too many manual steps. Going from the zip to a server you can log in to means building a CA image, generating certificates, copying the right ones into the build context, writing a CoreConfig.xml, building the database and server images, starting them in the right order, and registering an admin certificate. Each step is fine. Doing all of them by hand, and remembering the order next month, is not.

The 5.8 hardened release does not come up clean. The example config does not validate against its own schema, the health checks reference files that no longer exist, and one Dockerfile only builds on some hosts. More on each below.

I wanted something I could hand to someone else. The compose file, the config template and the certificate generator are all embedded in the script, so a teammate needs exactly two files: the script and the zip.

What a run does

  1. Preflight - write the embedded files to ./.tak, create ./.env on the first run, extract the zip, patch the vendor issues
  2. Config - render CoreConfig.xml from .env and validate it against the release's CoreConfig.xsd
  3. Certificates - generate a CA plus server, admin and user certificates and export them to ./certs/files
  4. Build - docker compose build
  5. Start - bring up the database, wait for the schema, start TAK Server
  6. Admin - register admin.pem with UserManager.jar
  7. Verify - check which certificate is served and that the admin console answers

It is safe to re-run. Certificates are generated once and reused, and everything tunable (ports, names, certificate subject) lives in .env, which is never overwritten.

When it finishes:

8089   CoT streaming, TLS with client certificate
8443   WebTAK and the non-admin UI. Admin console disabled here.
8444   federation
8446   certificate enrollment / OAuth
8451   admin console only

Import ./certs/files/admin.p12 into your browser (the password is atakatak unless you changed CAPASS) and open https://localhost:8451.

Browsing to port 8443 without a client certificate fails the TLS handshake and looks like the server is down. It is not. That is the client certificate requirement doing its job.

One file, everything embedded

The script carries its own compose file, config template and certificate generator as quoted heredocs and writes them to ./.tak on every run. The script stays the single source of truth, and there is nothing else to keep in sync.

write_assets() {
  mkdir -p "$WORK"

  cat > "$WORK/docker-compose.yml" <<'COMPOSE_EOF'
services:
  tak-database:
    build:
      context: ${TAK_SRC}
      dockerfile: docker/Dockerfile.hardened-takserver-db
    image: tak-database-hardened:${TAK_VERSION}
    container_name: ${DB_CONTAINER}
    hostname: ${DB_CONTAINER}
    networks:
      - takserver-net
    volumes:
      # Postgres is initdb'd during the image build; the first start copies
      # that into the named volume so data survives image rebuilds.
      - tak-db-data:/var/lib/postgresql/data
    restart: unless-stopped

  takserver:
    build:
      context: ${TAK_SRC}
      dockerfile: docker/Dockerfile.hardened-takserver
    image: takserver-hardened:${TAK_VERSION}
    container_name: ${TAKSERVER_CONTAINER}
    hostname: ${TAKSERVER_CONTAINER}
    depends_on:
      tak-database:
        condition: service_healthy
    environment:
      # The image's HEALTHCHECK curls the API with this client cert. Without
      # both variables the container never reports healthy.
      ADMIN_CERT_NAME: ${ADMIN_CERT_NAME}
      ADMIN_CERT_PASS: ${CAPASS}
    networks:
      - takserver-net
    ports:
      - "${PORT_COT_TLS}:${PORT_COT_TLS}"       # CoT streaming, TLS client cert
      - "${PORT_WEBTAK}:${PORT_WEBTAK}"         # WebTAK + non-admin UI
      - "${PORT_FEDERATION}:${PORT_FEDERATION}" # federation
      - "${PORT_ENROLLMENT}:${PORT_ENROLLMENT}" # cert enrollment / OAuth
      - "${PORT_ADMIN}:${PORT_ADMIN}"           # admin console
    restart: unless-stopped
COMPOSE_EOF

The heredoc delimiter is quoted (<<'COMPOSE_EOF'), so ${TAK_SRC} and friends reach disk untouched and Docker Compose expands them later from .env.

Three problems in the 5.8 release

These are the parts worth reading even if you never run the script.

The example config fails its own schema

CoreConfig.example.xml still uses the pre-5.x spelling of the rate limiter, <qos enabled="..."> with a <reportingRateLimiter> child. In the 5.8 CoreConfig.xsd, qos takes no attributes and the children are named differently. The template in the script uses the 5.8 spelling:

<qos>
    <deliveryRateLimiter enabled="false">
        <rateLimitRule clientThresholdCount="1000" reportingRateLimitSeconds="120"/>
        <rateLimitRule clientThresholdCount="5000" reportingRateLimitSeconds="180"/>
    </deliveryRateLimiter>
</qos>

The script also runs xmllint against the schema before building, so a bad edit shows up in seconds and not after a 20 minute build.

Containers stuck at (unhealthy)

Both images end their HEALTHCHECK with check_file_integrity.sh, which sha256-sums every path in critical_file_list.txt and fails if any single one is unreadable. The 5.8 lists name /usr/share/crypto-policies/back-ends/*/openssl.config, which current crypto-policies replaced with opensslcnf.config.

The result is both containers sitting at (unhealthy) forever while Postgres and the API are perfectly fine. The only evidence is in /opt/tak/health/*/health_check.log inside the container.

The integrity script skips lines that start with #, so commenting the stale paths out is enough:

STALE_INTEGRITY_PATHS=(
  /usr/share/crypto-policies/back-ends/LEGACY/openssl.config
  /usr/share/crypto-policies/back-ends/FUTURE/openssl.config
  /usr/share/crypto-policies/back-ends/DEFAULT/openssl.config
  /usr/share/crypto-policies/back-ends/FIPS/openssl.config
)
for list_file in "$SRC"/tak/health/*/critical_file_list.txt; do
  [ -f "$list_file" ] || continue
  for stale in "${STALE_INTEGRITY_PATHS[@]}"; do
    if grep -qxF "$stale" "$list_file"; then
      sed -i "s|^$(printf '%s' "$stale" | sed 's/[|.]/\\&/g')$|# &  # not shipped by current crypto-policies|" "$list_file"
      info "integrity list ($(basename "$(dirname "$list_file")")): commented out $stale"
    fi
  done
done

After the build, the script checks every remaining path in those lists inside the built images. The next time a base image drops a file, it is reported by name in seconds.

unknown flag: --exclude

Dockerfile.hardened-takserver-db uses COPY --exclude=, which is a labs-only flag, and the file has no # syntax= line. Whether it builds depends on which Dockerfile frontend your BuildKit defaults to. On some machines it works. On others it stops with this, after initdb has already run:

dockerfile parse error on line 252: unknown flag: --exclude

Pinning the labs frontend takes the host out of the equation:

for dockerfile in "$SRC"/docker/Dockerfile.*; do
  [ -f "$dockerfile" ] || continue
  grep -qE '^[[:space:]]*(COPY|ADD)[[:space:]].*--exclude' "$dockerfile" || continue
  head -1 "$dockerfile" | grep -q '^# syntax=' && continue
  sed -i '1i # syntax=docker/dockerfile:1.7-labs' "$dockerfile"
  info "pinned the labs Dockerfile frontend in $(basename "$dockerfile") (COPY --exclude needs it)"
done

Other things I learned along the way

Splitting WebTAK and the admin console

Each <connector> in CoreConfig.xml can expose a different part of the web tier. The attributes all default to true, so anything you do not turn off is reachable.

<connector port="@PORT_WEBTAK@" _name="https"
           enableWebtak="true"
           enableNonAdminUI="true"
           enableAdminUI="false"/>

<connector port="@PORT_ADMIN@" _name="admin_https"
           enableAdminUI="true"
           enableWebtak="false"
           enableNonAdminUI="false"/>

A server certificate with more than one name

The vendor's makeCert.sh gives a server certificate exactly one alt name, its CN. I wanted the same certificate to work as localhost, by container name, and over 127.0.0.1.

OpenSSL's config parser merges repeated section headers. So the script appends its own [ alt_names ] entries to config.cfg before calling the vendor script, and they end up in the same section as the one makeCert.sh adds:

if [ -n "${EXTRA_SANS:-}" ] && ! grep -q '^# local_tak extra SANs' config.cfg; then
  {
    printf '\n# local_tak extra SANs\n[ alt_names ]\n'
    printf '%b\n' "$EXTRA_SANS"
    printf '\n[ server ]\n'
  } >> config.cfg
fi

The trailing empty [ server ] header matters. makeCert.sh appends subjectAltName = @alt_names to the end of the file, and without that header the line would land inside [ alt_names ] and the SAN extension would never be requested.

HTTP 000 does not mean the server is down

The WebTAK connector requires a client certificate. Curling it without one fails the TLS handshake, and curl -w '%{http_code}' prints 000 no matter how long you wait. The readiness check has to present the admin certificate, the same way the image's own health check does:

code="$(docker exec "$TAKSERVER_CONTAINER" curl -sk -o /dev/null -w '%{http_code}' \
          --max-time 5 \
          --cert-type P12 --cert "/opt/tak/certs/files/${ADMIN_CERT_NAME}.p12:${CAPASS}" \
          "https://localhost:${PORT_WEBTAK}/Marti/api/version" 2>/dev/null || true)"

The wrong certificate on the wire

By default the script imports admin.p12 into the server keystore. CoreConfig sets keymanager="SunX509", and SunX509 does not filter key entries by extended key usage. With two RSA entries in the keystore it picks by alias order, so the server can end up presenting the admin client certificate to browsers. The verify step checks for exactly this and tells you how to fix it (IMPORT_ADMIN_P12_INTO_JKS=false in .env, then regenerate the certificates).

The script

The whole thing is about a thousand lines, most of it the embedded files and the --help text, so it is a download and not a code block: install-tak.sh

./install-tak.sh                        full install, safe to re-run
./install-tak.sh --no-build             skip image builds (config/cert changes only)
./install-tak.sh --certs-only           stop after generating certificates
./install-tak.sh --teardown             remove containers, keep the database volume
./install-tak.sh --teardown --all       also delete the volume, certificates and images
./install-tak.sh --help                 full documentation

It needs Docker with the Compose plugin, unzip, and about 15 GB of free disk. xmllint is optional.

Not covered: federation with other servers, LDAP or OAuth authentication, plugins, clustering, and certificates from a public CA. This is a single-host local deployment with a self-signed CA.

No comments yet