IT & Security

Running the CDA’s ITRANS Claims Director on Ubuntu 26.04 with .NET 10

Most dental practices in Canada submit insurance claims through ITRANS, the Canadian Dental Association’s claims network. The desktop piece of that is ITRANS2 Claims Director, or ICD: a small service that takes a claim your practice management software drops into a folder, signs it with your provider certificate, sends it to the right carrier, and hands back the response. It normally runs on Windows or macOS, tucked away on the same machine as the practice management system, and most people never think about it until it stops working.

I wanted to know whether it would run cleanly on Linux. ITRANS does maintain ICD, and they publish a Linux download right alongside the Windows and macOS ones. What they don’t publish is a proper Linux install guide, so the package lands in your lap with no real instructions and a handful of defaults left over from the other platforms. I run self-hosted infrastructure where it makes sense, and a claims service pinned to a single Windows box is exactly the kind of dependency that turns into a bad afternoon three years from now. So I took the official ICD v4.1.1 Linux package onto a clean Ubuntu 26.04 demo server and worked out what it takes to get it running under .NET 10. Here’s what that actually took, and the judgment calls along the way.

What you’re actually dealing with

The CDA distributes the official software, including the Linux build, at cda-adc.ca/get. Unpack the ICD package and you get roughly a hundred and fifty files: the application itself (ITRANS2.ICD.Linux.dll), a pile of Microsoft AspNetCore and Extensions assemblies, BouncyCastle for crypto, a Java keystore for the provider certificate, a couple of INI files, and a JSON runtime config. It’s a self-contained ASP.NET app that puts up a small web interface for status and configuration.

Three things about it were built for a different machine than the one I was installing on, and each one had to be dealt with before it would start.

It expects to live in specific /var paths. The app is hard-wired to work out of /var/CDA for its own files, /var/ICD for the claims it processes, and a set of network folders under /var/ccd (one per carrier: Alberta Blue Cross, the two Telus endpoints, Instream, Manitoba Blue Cross, and the web-service folder). These paths are baked into ICD.ini, so the cleanest approach is to build the layout the app already expects rather than fight it.

One config file points at a macOS runtime. CertApp.ini, which handles the certificate side, has a line reading DotNetProgramLocation=/usr/local/share/dotnet/dotnet. That’s the standard macOS install path for the .NET runtime. On Ubuntu the runtime lives somewhere else entirely (/lib/dotnet/dotnet in my case), so that line has to be repointed or the certificate tooling can’t find dotnet to invoke.

It targets a runtime that has been dead for years. This is the interesting one. The app’s runtimeconfig.json asks for .NET Core 2.1:

```json
{
  "runtimeOptions": {
    "tfm": "netcoreapp2.1",
    "framework": {
      "name": "Microsoft.NETCore.App",
      "version": "2.1.0"
    }
  }
}
```

.NET Core 2.1 reached end of life in August 2021. You will not install it on a current Ubuntu, and you shouldn’t want to: it stopped getting security fixes years ago. The machine I was on had .NET 10, the current long-term-support release. The question was whether a 2.1-targeted app would run on a runtime eight major versions newer.

The one decision that made it work: roll-forward

.NET has a feature called roll-forward. By default, an app built for one major version won’t start on a different major version, because Microsoft can’t promise binary compatibility across that gap. But you can explicitly opt in and tell the runtime “if the version I asked for isn’t here, use the newest one that is.” You do that by adding rollForward: LatestMajor to the runtime config:

```json
{
  "runtimeOptions": {
    "tfm": "netcoreapp2.1",
    "rollForward": "LatestMajor",
    "framework": {
      "name": "Microsoft.NETCore.App",
      "version": "2.1.0"
    }
  }
}
```

With that one line, the .NET 10 runtime accepts the 2.1 app and starts it. The app came up, put its web interface on port 9660, and ran.

I want to be honest about what this is and isn’t. Roll-forward from 2.1 all the way to 10 is a compatibility path the vendor never tested, because the vendor never intended this app to run anywhere but Windows and macOS on the runtime they shipped it for. It worked on my demo server. That is genuine and useful information, but it is not the same as “this is supported and safe for production.” Anything an app relies on that changed across those eight releases (a serialization default, a TLS behaviour, a filesystem assumption) could surface later as a subtle failure rather than a clean crash. The only way to trust it is to run real claim traffic through it and watch. I’ll come back to this.

Scripting the install

Once I knew the three fixes, I put them in a single install script so the process was repeatable and, more importantly, so I could dry-run it before touching a real machine. The script does seven things in order: checks that the required application files are present, locates the dotnet runtime, builds the /var directory layout, copies the app into place, patches the macOS path out of CertApp.ini, writes the roll-forward runtime config, and generates a launcher that runs with the correct runtime. It can optionally install a systemd service so the claims director comes back up on reboot.

Two design choices earned their keep. First, a --dry-run mode combined with a PREFIX variable, so you can point the whole thing at /tmp/icd-test and watch exactly what it would do without writing a byte to /var. On anything that handles patient claims, being able to rehearse the install fully before running it for real is not optional. Second, the script backs up every file it edits (CertApp.ini.bak, runtimeconfig.json.bak) so there is always a way back to the shipped state.

Here is the full script:

```bash
#!/usr/bin/env bash
#
# install-icd.sh — Install ICD v4.1.1 (ITRANS2 Claims Director) on Ubuntu.
#
# This package was authored for macOS, so a few things need fixing on Linux:
#   * The app expects to live in /var/CDA and work out of /var/ICD + /var/ccd/*.
#   * CertApp.ini points DotNetProgramLocation at the macOS path
#     /usr/local/share/dotnet/dotnet — we repoint it at the real dotnet.
#   * runtimeconfig.json targets .NET Core 2.1 (end-of-life, not available on
#     modern Ubuntu). We enable roll-forward so a newer installed runtime
#     (e.g. the one at /lib/dotnet) is accepted. This is a compatibility
#     workaround the vendor did not test — validate by actually running it.
#
# Usage:
#   sudo ./install-icd.sh                 # real install into /var/CDA etc.
#   ./install-icd.sh --service            # also install a systemd unit
#   PREFIX=/tmp/icd-test ./install-icd.sh --dry-run   # safe local dry-run
#
# Overridable via environment:
#   PREFIX      prepended to all target paths (default: empty = real /var)
#   DOTNET      path to the dotnet binary (default: auto-detect)
#   APP_USER    user/group to own and run the app (default: current sudo user)
#
set -euo pipefail

PREFIX="${PREFIX:-}"
APP_HOME="${PREFIX}/var/CDA"
CLAIMS_DIR="${PREFIX}/var/ICD"
CCD_ROOT="${PREFIX}/var/ccd"
CCD_DIRS=(abc telusa telusb instream mbc ccdws)
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_USER="${APP_USER:-${SUDO_USER:-$(id -un)}}"

DRY_RUN=0
INSTALL_SERVICE=0
for arg in "$@"; do
  case "$arg" in
    --dry-run) DRY_RUN=1 ;;
    --service) INSTALL_SERVICE=1 ;;
    *) echo "Unknown option: $arg" >&2; exit 2 ;;
  esac
done

log()  { printf '  %s\n' "$*"; }
step() { printf '\n==> %s\n' "$*"; }
run()  { if [ "$DRY_RUN" -eq 1 ]; then echo "  [dry-run] $*"; else eval "$@"; fi; }

# 0. Preflight
step "Preflight checks"
REQUIRED_FILES=(ITRANS2.ICD.Linux.dll ITRANS2.ICD.Linux.runtimeconfig.json \
                ICD.ini CertApp.ini providerkeys cacerts n-cpl.json)
missing=0
for f in "${REQUIRED_FILES[@]}"; do
  if [ ! -e "$SRC_DIR/$f" ]; then
    echo "  MISSING required file: $f" >&2; missing=1
  fi
done
[ "$missing" -eq 0 ] || { echo "Run this script from the unpacked ICD folder." >&2; exit 1; }
log "All required application files present."

if [ "$DRY_RUN" -eq 0 ] && [ -z "$PREFIX" ] && [ "$(id -u)" -ne 0 ]; then
  echo "  This installs into /var — re-run with sudo (or use PREFIX=... --dry-run)." >&2
  exit 1
fi

# 1. Locate the dotnet runtime
step "Locating dotnet runtime"
DOTNET="${DOTNET:-}"
if [ -z "$DOTNET" ]; then
  for cand in /lib/dotnet/dotnet /usr/lib/dotnet/dotnet /usr/share/dotnet/dotnet \
              "$HOME/.dotnet/dotnet" "$(command -v dotnet 2>/dev/null || true)"; do
    if [ -n "$cand" ] && [ -x "$cand" ]; then DOTNET="$cand"; break; fi
  done
fi
if [ -z "$DOTNET" ] || [ ! -x "$DOTNET" ]; then
  echo "  Could not find a dotnet binary." >&2
  echo "  Install it, or pass DOTNET=/lib/dotnet/dotnet explicitly." >&2
  [ "$DRY_RUN" -eq 1 ] && log "(dry-run) continuing without a runtime" || exit 1
else
  DOTNET_ROOT="$(dirname "$DOTNET")"
  log "Using dotnet at: $DOTNET"
  log "Installed runtimes:"
  "$DOTNET" --list-runtimes 2>/dev/null | sed 's/^/    /' || log "    (could not list runtimes)"
fi

# 2. Create the directory layout
step "Creating directory layout"
run "mkdir -p '$APP_HOME' '$CLAIMS_DIR'"
for d in "${CCD_DIRS[@]}"; do run "mkdir -p '$CCD_ROOT/$d'"; done
log "Created $APP_HOME, $CLAIMS_DIR, and ${#CCD_DIRS[@]} network folders under $CCD_ROOT."

# 3. Copy application files into place
step "Copying application into $APP_HOME"
run "cp -a '$SRC_DIR/.' '$APP_HOME/'"
run "rm -f '$APP_HOME/install-icd.sh'"
log "Application files copied."

# 4. Fix the dotnet path baked into CertApp.ini (macOS -> this host)
step "Patching CertApp.ini DotNetProgramLocation"
if [ -n "${DOTNET:-}" ]; then
  run "sed -i.bak 's#^DotNetProgramLocation=.*#DotNetProgramLocation=$DOTNET#' '$APP_HOME/CertApp.ini'"
  log "Set DotNetProgramLocation=$DOTNET (original saved as CertApp.ini.bak)."
else
  log "Skipped (no dotnet located)."
fi

# 5. Enable roll-forward so the .NET 2.1 target runs on a newer runtime
step "Enabling runtime roll-forward"
RCFG="$APP_HOME/ITRANS2.ICD.Linux.runtimeconfig.json"
if [ "$DRY_RUN" -eq 1 ]; then
  log "[dry-run] would add rollForward=LatestMajor to $RCFG"
else
  cp -a "$RCFG" "$RCFG.bak"
  cat > "$RCFG" <<'JSON'
{
  "runtimeOptions": {
    "tfm": "netcoreapp2.1",
    "rollForward": "LatestMajor",
    "framework": {
      "name": "Microsoft.NETCore.App",
      "version": "2.1.0"
    }
  }
}
JSON
  log "Added rollForward=LatestMajor (original saved as runtimeconfig.json.bak)."
fi

# 6. Generate a launcher that uses the absolute dotnet path
step "Writing launcher $APP_HOME/run-icd.sh"
if [ "$DRY_RUN" -eq 1 ]; then
  log "[dry-run] would write run-icd.sh referencing $DOTNET"
else
  cat > "$APP_HOME/run-icd.sh" <<EOF
#!/usr/bin/env bash
# Generated by install-icd.sh — launches ICD with the correct runtime.
set -euo pipefail
export DOTNET_ROOT="${DOTNET_ROOT:-/lib/dotnet}"
export DOTNET_ROLL_FORWARD=LatestMajor
cd "$APP_HOME"
exec "${DOTNET:-/lib/dotnet/dotnet}" "$APP_HOME/ITRANS2.ICD.Linux.dll" "\$@"
EOF
  chmod +x "$APP_HOME/run-icd.sh"
  chmod +x "$APP_HOME/LaunchICD.command" 2>/dev/null || true
  log "Launcher ready: $APP_HOME/run-icd.sh"
fi

# 7. Ownership
step "Setting ownership to $APP_USER"
run "chown -R '$APP_USER':'$APP_USER' '$APP_HOME' '$CLAIMS_DIR' '$CCD_ROOT'"

# 8. Optional systemd service
if [ "$INSTALL_SERVICE" -eq 1 ]; then
  step "Installing systemd service (icd.service)"
  UNIT="${PREFIX}/etc/systemd/system/icd.service"
  if [ "$DRY_RUN" -eq 1 ]; then
    log "[dry-run] would write $UNIT and enable it"
  else
    mkdir -p "$(dirname "$UNIT")"
    cat > "$UNIT" <<EOF
[Unit]
Description=ICD v4.1.1 (ITRANS2 Claims Director)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=$APP_USER
WorkingDirectory=$APP_HOME
Environment=DOTNET_ROOT=${DOTNET_ROOT:-/lib/dotnet}
Environment=DOTNET_ROLL_FORWARD=LatestMajor
ExecStart=${DOTNET:-/lib/dotnet/dotnet} $APP_HOME/ITRANS2.ICD.Linux.dll
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF
    if [ -z "$PREFIX" ]; then
      systemctl daemon-reload
      systemctl enable icd.service
      log "Service installed and enabled. Start with: sudo systemctl start icd"
    else
      log "Wrote unit to $UNIT (PREFIX set — not enabling)."
    fi
  fi
fi

step "Done"
```

Running it and confirming it’s alive

First, the runtime. On Ubuntu 26.04 .NET 10 is in the distribution’s own repositories, so there’s no Microsoft feed to add and no manual download. It’s one command:

```bash
sudo apt install dotnet10
```

That puts a supported, distro-maintained runtime on the box, which matters: security updates arrive through the same apt upgrade as everything else, instead of a runtime you have to remember to patch by hand. The install script auto-detects it wherever apt places it (/lib/dotnet or /usr/lib/dotnet).

Now the dry run, which touches nothing real:

```bash
PREFIX=/tmp/icd-test DOTNET=/lib/dotnet/dotnet ./install-icd.sh --dry-run --service
```

Read that output carefully. It prints every action it would take. When it looks right, the real install:

```bash
sudo ./install-icd.sh --service
```

Then start it and confirm the web interface is actually listening on port 9660:

```bash
sudo systemctl start icd
ss -tlnp | grep 9660
```

If the port is open, the .NET 2.1 app is running on .NET 10. That was the whole exercise.

The security part, which is the real point

Getting software to start is the easy half. This is a claims service that signs traffic with a provider certificate and moves patient and billing data to insurance carriers, so a few things matter more than whether it boots.

There are real secrets in the package. CertApp.ini ships with the Java keystore password sitting in the file in plain text, and providerkeys is the keystore itself. Treat both as sensitive. They belong to a user account with locked-down file permissions, never in a repository, never in a screenshot, and never in a blog post (which is why you won’t find that password here). If a package like this passed through anywhere it could have been copied, rotate the credential.

The web interface exposes itself, and you can’t config your way out of it. Port 9660 is a local management surface with no business being reachable from the wider network, let alone the internet. The catch: when it starts, the app logs Overriding address(es)... Now listening on: https://0.0.0.0:9660. That 0.0.0.0 means every network interface on the box, and the “overriding” warning means the bind address is hardcoded in the application itself. You cannot move it to localhost through a config file; the app ignores you. So the only real control is at the perimeter: a host firewall rule (ufw, nftables) that blocks 9660 from everywhere except the machines that legitimately need it, and a port scan from another host to confirm the rule actually holds. Assuming the app would respect a “bind to localhost” setting is exactly the kind of assumption that leaves a management port open to the whole subnet.

On Ubuntu that lockdown is a couple of ufw rules. Deny the port outright, or allow it only from the admin subnet that legitimately needs it and deny the rest:

```bash
# Option A: block 9660 from everywhere
sudo ufw deny 9660/tcp

# Option B: allow only a trusted subnet, deny the rest
sudo ufw allow from 192.168.1.0/24 to any port 9660 proto tcp
sudo ufw deny 9660/tcp
```

Then, and this is the part people skip, confirm the rule actually holds by scanning from a different machine. Checking on the box itself proves nothing about what the network can reach:

nmap -p 9660 <server-ip>     # want: filtered or closed, not open

Run it as an unprivileged user. The script installs the systemd unit to run as a normal user, not root. A claims service does not need root to do its job, and not giving it root means a problem in the app is contained to one account instead of the whole box.

The roll-forward is a proof of concept, not a blessing. I’ll say it once more because it’s the honest bottom line. Running a 2.1-targeted app on .NET 10 worked on a demo server. Before I would put this in front of a live practice I’d run real claim submissions through every carrier it talks to, watch the logs across a full billing cycle, and have a tested rollback to a supported platform ready. “It started” is where the testing begins, not where it ends.

Why this matters more than one app

Here’s the part that makes this worth writing about rather than just filing in a runbook.

For years the practical answer to “can a dental practice run its core systems on Linux?” has been “mostly, except for the pieces that won’t.” The database behind a practice management system is rarely the problem. Open Dental, the PMS I’ve administered and built practices around, keeps its data in MySQL/MariaDB, and that database server runs perfectly well on Linux. Plenty of the surrounding infrastructure I run already is Linux by choice: file storage, HR, training platforms, backups.

The stubborn holdout has been claims. ITRANS is not optional for a Canadian practice; it’s how you get paid. If the claims director only runs on Windows or macOS, you keep a Windows machine in the critical path no matter how much of the rest you’ve moved. One unavoidable dependency drags along everything that comes with it, and quietly sets the security and cost floor for the whole practice.

Showing that ICD runs on Linux removes that holdout. When the last thing tying the core stack to Windows is gone, three things change for a practice.

The security surface shrinks. Windows is the platform ransomware is written for, and a dental practice sits squarely in the target set. A hardened Linux server running only the services it needs, patched through one apt upgrade, with each service running unprivileged, is a smaller and more defensible target than a general-purpose Windows box. Fewer moving parts, fewer of them exposed, is most of the job in security.

Cost drops, and keeps dropping. No Windows Server license, no client access licenses, no per-seat tax on the server side. The stack is free software and the hardware requirements are modest enough that a practice isn’t pushed into a new server every time an operating system reaches end of life. That last point is not hypothetical. OS end-of-life dates have a way of turning working hardware into “unsupported” hardware on a vendor’s schedule rather than yours.

The infrastructure lasts longer. This is the one I care about most. A Linux LTS release, and an LTS runtime like the .NET 10 this app now runs on, come with multi-year support windows delivered through the same package manager as everything else. You patch in place. You are not re-platforming the practice every few years because someone decided your OS is done. Infrastructure that lasts is infrastructure you can actually reason about, because it isn’t shifting under you.

None of this is a reason to rip out a working Windows setup tomorrow. It’s a reason to know the option is real, and to design new builds with it on the table. A practice that can run its whole back office on hardware and software it controls, on a platform that gets cheaper and more defensible the more of it you consolidate, is in a fundamentally stronger position than one that assumed it was stuck.

The standard I hold it to

Knowing whether a critical piece of your practice’s plumbing is actually tied to one operating system, or just assumed to be, is the difference between running your infrastructure and hoping it keeps running. ITRANS maintains ICD and even offers a Linux download; what they don’t offer is a Linux playbook, so making it run cleanly is on me. That’s fine. But when I can take the same claims service, stand it up on a hardened Linux box I control, run it under a supported runtime, and put it behind my own firewall as an unprivileged service, I’m not guessing about where a core dependency lives or how exposed it is. I can answer for it.

That’s the same standard I hold every technology decision in a practice to. If a regulator or a security incident forced me to explain where the data goes and how it’s protected, could I answer clearly? For the claims path, now I can.