Compare commits

7 Commits
1 ... main

9 changed files with 601 additions and 1 deletions

12
.gitignore vendored
View File

@@ -4,6 +4,11 @@ config.yaml
# ISO output files # ISO output files
*.iso *.iso
# Vendored/runtime assets fetched outside git
dcmtk-bin/
legacy/raw/
.local/
# Temp directories # Temp directories
/tmp/dicomdir_* /tmp/dicomdir_*
@@ -20,3 +25,10 @@ Thumbs.db
# Local-only docs tell @VArtzy<farrelnikoson@gmail.com> for request # Local-only docs tell @VArtzy<farrelnikoson@gmail.com> for request
docs/phases docs/phases
# Keep placeholder docs for ignored asset directories
!dcmtk-bin/
!dcmtk-bin/README.md
!legacy/
!legacy/raw/
!legacy/raw/README.md

View File

@@ -25,6 +25,9 @@ The service needs:
- MicroDicom files - MicroDicom files
- writable temp storage - writable temp storage
Large runtime assets are intentionally not stored in git.
Use the setup scripts in `scripts/` to stage local copies in ignored directories.
## Setup ## Setup
Before running the service, make sure the VM has: Before running the service, make sure the VM has:
- Go installed, if you are building on that machine - Go installed, if you are building on that machine
@@ -33,6 +36,27 @@ Before running the service, make sure the VM has:
- network access to PACS, the patient API, and the CD publisher - network access to PACS, the patient API, and the CD publisher
- a writable temp directory - a writable temp directory
Stage local runtime assets if needed:
```bash
scripts/setup-dcmtk.sh --source-dir /path/to/dcmtk/bin
scripts/setup-microdicom.sh --source-dir /path/to/microdicom
```
Or download your hosted release assets directly:
```bash
scripts/setup-dcmtk.sh --archive-url https://<gitea-host>/<owner>/<repo>/releases/download/<tag>/dcmtk-bin.tar.gz
scripts/setup-microdicom.sh --archive-url https://<gitea-host>/<owner>/<repo>/releases/download/<tag>/microdicom.zip
```
Example:
```bash
scripts/setup-dcmtk.sh --archive-url https://devone.aplikasi.web.id/gitea/farrel/dicom-iso/releases/download/1/dcmtk-bin.tar.gz
scripts/setup-microdicom.sh --archive-url https://devone.aplikasi.web.id/gitea/farrel/dicom-iso/releases/download/1/microdicom.zip
```
Create a local config file from the template: Create a local config file from the template:
```bash ```bash
@@ -40,6 +64,7 @@ cp config.example.yaml config.yaml
``` ```
Then adjust the paths, hosts, ports, and tokens for your environment. Then adjust the paths, hosts, ports, and tokens for your environment.
For local staging via the setup scripts, point config at `.local/dcmtk-bin/` and `.local/microdicom/`.
## Build ## Build
A normal Go build is enough in a friendly environment: A normal Go build is enough in a friendly environment:

View File

@@ -0,0 +1,265 @@
## Minimal App Setup
Clone this repository
```bash
scripts/setup-dcmtk.sh --archive-url https://devone.aplikasi.web.id/gitea/farrel/dicom-iso/releases/download/1/dcmtk-bin.tar.gz
scripts/setup-microdicom.sh --archive-url https://devone.aplikasi.web.id/gitea/farrel/dicom-iso/releases/download/1/microdicom.zip
```
Create a local config file from the template:
```bash
cp config.example.yaml config.yaml
```
Then adjust the paths, hosts, ports, and tokens for your environment.
For local staging via the setup scripts, point config at `.local/dcmtk-bin/` and `.local/microdicom/`.
## Build
A normal Go build is enough in a friendly environment:
```bash
go build -o mkiso-server .
```
## Run
You can run the service directly:
```bash
./mkiso-server
```
Or pass a config path explicitly:
```bash
./mkiso-server /path/to/config.yaml
```
By default, the app looks for `./config.yaml`.
## Health check
After startup, check:
```bash
curl http://127.0.0.1:8080/api/health
```
## Config
Use `config.example.yaml` as the starting point.
Keep real `config.yaml` local and untracked.
## Scp to VPS
```bash
scp ./mkiso-server <user-ssh>@<server-ip>:/opt/dicom-iso/
```
## Minimal Setup VPS + DCM4CHE
```bash
# SSH to server
ssh <user-ssh>@<server-ip>
# Create user 'one'
sudo useradd -m -s /bin/bash one
echo 'one:sasone102938' | sudo chpasswd
sudo usermod -aG sudo one
# Install base packages
sudo apt update
sudo apt install -y zsh git curl wget vim ca-certificates gnupg lsb-release util-linux docker.io docker-compose-plugin
fail2ban
# Enable docker and add user to docker group
sudo systemctl enable --now docker
sudo usermod -aG docker one
# Switch to user 'one'
sudo -iu one
# Install zsh + Oh My Zsh
chsh -s /usr/bin/zsh
export RUNZSH=no
export CHSH=no
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
# Set theme
sed -i 's/^ZSH_THEME=.*/ZSH_THEME="tjkirch"/' ~/.zshrc
# Install Oh My Zsh plugins
git clone https://github.com/zsh-users/zsh-autosuggestions
${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
git clone https://github.com/zsh-users/zsh-syntax-highlighting
${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
# Enable plugins
sed -i 's/^plugins=.*/plugins=(git zsh-autosuggestions zsh-syntax-highlighting)/' ~/.zshrc
# Add aliases and history config
cat >> ~/.zshrc <<'EOF'
# Custom aliases
alias lh='ls -lh'
alias lah='ls -lah'
# History settings
HISTFILE=~/.zsh_history
HISTSIZE=100000
SAVEHIST=100000
setopt APPEND_HISTORY
setopt INC_APPEND_HISTORY
setopt SHARE_HISTORY
setopt EXTENDED_HISTORY
setopt HIST_IGNORE_DUPS
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_EXPIRE_DUPS_FIRST
setopt HIST_IGNORE_SPACE
setopt HIST_REDUCE_BLANKS
EOF
# Install fzf
git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf
~/.fzf/install --all
# Reload shell
source ~/.zshrc
# Prepare dcm4chee directories
sudo mkdir -p /var/local/dcm4chee-arc/{ldap,slapd.d,db,wildfly,storage}
sudo chown -R one:one /var/local/dcm4chee-arc
# Ensure timezone file exists
cat /etc/timezone || echo "Asia/Jakarta" | sudo tee /etc/timezone
# Create working dir
mkdir -p ~/dcm4chee
cd ~/dcm4chee
# Record setup session
script -aq ~/setup-dcm4chee-$(date +%F-%H%M%S).log
# Create docker-compose.yml
cat > docker-compose.yml <<'EOF'
version: "3"
services:
ldap:
image: dcm4che/slapd-dcm4chee:2.6.10-34.2
logging:
driver: json-file
options:
max-size: "10m"
ports:
- "389:389"
environment:
STORAGE_DIR: /storage/fs1
volumes:
- /var/local/dcm4chee-arc/ldap:/var/lib/openldap/openldap-data
- /var/local/dcm4chee-arc/slapd.d:/etc/openldap/slapd.d
db:
image: dcm4che/postgres-dcm4chee:17.4-34
logging:
driver: json-file
options:
max-size: "10m"
ports:
- "5432:5432"
environment:
POSTGRES_DB: pacsdb
POSTGRES_USER: pacs
POSTGRES_PASSWORD: pacs
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- /var/local/dcm4chee-arc/db:/var/lib/postgresql/data
arc:
image: dcm4che/dcm4chee-arc-psql:5.34.2
logging:
driver: json-file
options:
max-size: "10m"
ports:
- "8080:8080"
- "8443:8443"
- "9990:9990"
- "9993:9993"
- "11112:11112"
- "2762:2762"
- "2575:2575"
- "12575:12575"
environment:
POSTGRES_DB: pacsdb
POSTGRES_USER: pacs
POSTGRES_PASSWORD: pacs
WILDFLY_CHOWN: /storage
WILDFLY_WAIT_FOR: ldap:389 db:5432
depends_on:
- ldap
- db
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- /var/local/dcm4chee-arc/wildfly:/opt/wildfly/standalone
- /var/local/dcm4chee-arc/storage:/storage
EOF
# Start dcm4chee
docker-compose -p dcm4chee up -d
# Verify
docker-compose -p dcm4chee ps
ss -tulpn | grep -E '389|5432|8080|8443|9990|9993|11112|2762|2575|12575'
tail -f /var/local/dcm4chee-arc/wildfly/log/server.log
# UI
# http://<server-ip>:8080/dcm4chee-arc/ui2
# https://<server-ip>:8443/dcm4chee-arc/ui2
# Basic firewall
exit
sudo ufw allow OpenSSH
sudo ufw allow 8080/tcp
sudo ufw allow 8443/tcp
sudo ufw allow 11112/tcp
sudo ufw allow 2575/tcp
sudo ufw allow 2762/tcp
sudo ufw allow 12575/tcp
sudo ufw enable
sudo ufw status verbose
# Enable fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status
```
## PACS Server set up
in config file, find pacs section
you can change ae_title: "DCM4CHEE" (default)
## OUR_AE set up
Create new AET in https://<ip_>:8443/dcm4chee-arc/ui2/en/device
warning: set host to not localhost if you use docker-compose!
set to machine's private local ip (ex: 10.0.72.92) or public (not recommend)
in config file, find our_ae section
you can change ae_title: <created_ae>
port: <set_port>
## Make it service
```bash
sudo useradd --system --home /opt/dicom-iso --shell /usr/sbin/nologin mkiso
sudo chown -R mkiso:mkiso /opt/dicom-iso
sudo systemctl restart mkiso-server
sudo systemctl status mkiso-server
```
If user already exists, just run:
```bash
sudo chown -R mkiso:mkiso /opt/dicom-iso
sudo systemctl restart mkiso-server
```

View File

@@ -29,6 +29,13 @@ The service needs:
- a writable temp directory - a writable temp directory
- a free port range for `storescp` - a free port range for `storescp`
If you do not manage these assets globally on the VM, you can stage them locally first:
```bash
scripts/setup-dcmtk.sh --source-dir /path/to/dcmtk/bin --install-dir /opt/dicom-iso/dcmtk-bin
scripts/setup-microdicom.sh --source-dir /path/to/microdicom --install-dir /opt/dicom-iso/microdicom
```
## Build note ## Build note
The build environment cannot depend on public internet access. The build environment cannot depend on public internet access.
That means the binary must be built through an approved offline-friendly path. That means the binary must be built through an approved offline-friendly path.

18
docs/repo-size-cleanup.md Normal file
View File

@@ -0,0 +1,18 @@
# Repo size cleanup
Implemented in the working tree:
- removed tracked binaries from `dcmtk-bin/`
- removed tracked raw assets from `legacy/raw/`
- added `.gitignore` protections
- added setup scripts for DCMTK and MicroDicom
## Finish the cleanup in git history
Rewriting history is still required to shrink the remote repository size.
```bash
git filter-repo --path dcmtk-bin --path legacy/raw --invert-paths
git push --force --all
git push --force --tags
```
Coordinate this with any collaborators first.

View File

@@ -142,7 +142,7 @@ func RunFindSCUStudyUIDs(ctx context.Context, bin, ourAE, pacsAE, pacsHost strin
uidRe := regexp.MustCompile(`\(0020,000d\) UI \[([^\]]+)\]`) uidRe := regexp.MustCompile(`\(0020,000d\) UI \[([^\]]+)\]`)
seen := make(map[string]bool) seen := make(map[string]bool)
for _, match := range uidRe.FindAllStringSubmatch(combined, -1) { for _, match := range uidRe.FindAllStringSubmatch(combined, -1) {
uid := strings.TrimSpace(match[1]) uid := strings.Trim(match[1], " \t\r\n\x00")
if uid == "" || seen[uid] { if uid == "" || seen[uid] {
continue continue
} }

14
scripts/purge-git-history.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cat <<'EOF'
This rewrites git history to remove large vendored assets.
Run only after committing the current tree cleanup and coordinating with collaborators.
Commands:
git filter-repo --path dcmtk-bin --path legacy/raw --invert-paths
git push --force --all
git push --force --tags
Afterward, collaborators should reclone or hard-reset to the rewritten history.
EOF

139
scripts/setup-dcmtk.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALL_DIR="${INSTALL_DIR:-$ROOT_DIR/.local/dcmtk-bin}"
REQUIRED_BINS=(storescp movescu storescu findscu dcmdump dcmodify getscu echoscu dcmj2pnm)
SOURCE_DIR=""
ARCHIVE_URL=""
TMP_DIR=""
usage() {
cat <<'EOF'
Usage:
scripts/setup-dcmtk.sh [--source-dir DIR | --archive-url URL] [--install-dir DIR]
Behavior:
- Copies required DCMTK binaries into a local ignored directory.
- If --source-dir is omitted, binaries are resolved from PATH.
- If --archive-url is given, the script downloads and extracts an archive,
then searches for the required binaries inside it.
Examples:
scripts/setup-dcmtk.sh --source-dir /opt/dcmtk/bin
scripts/setup-dcmtk.sh --archive-url https://github.com/<owner>/<repo>/releases/download/<tag>/dcmtk-bin.tar.gz
scripts/setup-dcmtk.sh --install-dir /data/dcmtk-bin
EOF
}
download() {
local url="$1"
local out="$2"
if command -v curl >/dev/null 2>&1; then
curl -fL "$url" -o "$out"
elif command -v wget >/dev/null 2>&1; then
wget -O "$out" "$url"
else
echo "need curl or wget to download $url" >&2
exit 1
fi
}
extract_archive() {
local archive="$1"
local dest="$2"
case "$archive" in
*.tar.gz|*.tgz) tar -xzf "$archive" -C "$dest" ;;
*.tar.xz) tar -xJf "$archive" -C "$dest" ;;
*.tar) tar -xf "$archive" -C "$dest" ;;
*.zip)
command -v unzip >/dev/null 2>&1 || { echo "unzip is required for $archive" >&2; exit 1; }
unzip -q "$archive" -d "$dest"
;;
*)
echo "unsupported archive format: $archive" >&2
exit 1
;;
esac
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir)
SOURCE_DIR="${2:?missing value for --source-dir}"
shift 2
;;
--archive-url)
ARCHIVE_URL="${2:?missing value for --archive-url}"
shift 2
;;
--install-dir)
INSTALL_DIR="${2:?missing value for --install-dir}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -n "$SOURCE_DIR" && -n "$ARCHIVE_URL" ]]; then
echo "use only one of --source-dir or --archive-url" >&2
exit 1
fi
cleanup() {
[[ -n "$TMP_DIR" && -d "$TMP_DIR" ]] && rm -rf "$TMP_DIR"
}
trap cleanup EXIT
if [[ -n "$ARCHIVE_URL" ]]; then
TMP_DIR="$(mktemp -d)"
archive_name="$(basename "${ARCHIVE_URL%%\?*}")"
[[ -n "$archive_name" && "$archive_name" != "/" ]] || archive_name="archive.tar.gz"
archive="$TMP_DIR/$archive_name"
download "$ARCHIVE_URL" "$archive"
mkdir -p "$TMP_DIR/extracted"
extract_archive "$archive" "$TMP_DIR/extracted"
SOURCE_DIR="$TMP_DIR/extracted"
fi
mkdir -p "$INSTALL_DIR"
resolve_bin() {
local name="$1"
if [[ -n "$SOURCE_DIR" ]]; then
local candidate
candidate="$(find "$SOURCE_DIR" -type f -name "$name" -perm -u+x | head -n 1 || true)"
[[ -n "$candidate" ]] || candidate="$(find "$SOURCE_DIR" -type f -name "$name" | head -n 1 || true)"
[[ -n "$candidate" ]] || { echo "missing binary in source: $name" >&2; return 1; }
printf '%s\n' "$candidate"
return 0
fi
command -v "$name" >/dev/null 2>&1 || { echo "binary not found in PATH: $name" >&2; return 1; }
command -v "$name"
}
for bin in "${REQUIRED_BINS[@]}"; do
src="$(resolve_bin "$bin")"
install -m 0755 "$src" "$INSTALL_DIR/$bin"
echo "installed $bin -> $INSTALL_DIR/$bin"
done
cat <<EOF
Done.
Set these config values:
dcmtk:
storescp: "$INSTALL_DIR/storescp"
movescu: "$INSTALL_DIR/movescu"
storescu: "$INSTALL_DIR/storescu"
EOF

120
scripts/setup-microdicom.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALL_DIR="${INSTALL_DIR:-$ROOT_DIR/.local/microdicom}"
SOURCE_DIR=""
ARCHIVE_URL=""
TMP_DIR=""
usage() {
cat <<'EOF'
Usage:
scripts/setup-microdicom.sh [--source-dir DIR | --archive-url URL] [--install-dir DIR]
Behavior:
- Copies a prepared MicroDicom directory into a local ignored directory.
- With --archive-url, downloads and extracts an archive, then searches for
a directory containing AUTORUN.INF and MICROD/.
Example:
scripts/setup-microdicom.sh --archive-url https://github.com/<owner>/<repo>/releases/download/<tag>/microdicom.zip
EOF
}
download() {
local url="$1"
local out="$2"
if command -v curl >/dev/null 2>&1; then
curl -fL "$url" -o "$out"
elif command -v wget >/dev/null 2>&1; then
wget -O "$out" "$url"
else
echo "need curl or wget to download $url" >&2
exit 1
fi
}
extract_archive() {
local archive="$1"
local dest="$2"
case "$archive" in
*.tar.gz|*.tgz) tar -xzf "$archive" -C "$dest" ;;
*.tar.xz) tar -xJf "$archive" -C "$dest" ;;
*.tar) tar -xf "$archive" -C "$dest" ;;
*.zip)
command -v unzip >/dev/null 2>&1 || { echo "unzip is required for $archive" >&2; exit 1; }
unzip -q "$archive" -d "$dest"
;;
*)
echo "unsupported archive format: $archive" >&2
exit 1
;;
esac
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir)
SOURCE_DIR="${2:?missing value for --source-dir}"
shift 2
;;
--archive-url)
ARCHIVE_URL="${2:?missing value for --archive-url}"
shift 2
;;
--install-dir)
INSTALL_DIR="${2:?missing value for --install-dir}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -n "$SOURCE_DIR" && -n "$ARCHIVE_URL" ]]; then
echo "use only one of --source-dir or --archive-url" >&2
exit 1
fi
cleanup() {
[[ -n "$TMP_DIR" && -d "$TMP_DIR" ]] && rm -rf "$TMP_DIR"
}
trap cleanup EXIT
if [[ -n "$ARCHIVE_URL" ]]; then
TMP_DIR="$(mktemp -d)"
archive_name="$(basename "${ARCHIVE_URL%%\?*}")"
[[ -n "$archive_name" && "$archive_name" != "/" ]] || archive_name="microdicom.zip"
archive="$TMP_DIR/$archive_name"
download "$ARCHIVE_URL" "$archive"
mkdir -p "$TMP_DIR/extracted"
extract_archive "$archive" "$TMP_DIR/extracted"
SOURCE_DIR="$(find "$TMP_DIR/extracted" -type f -name AUTORUN.INF -printf '%h\n' | head -n 1 || true)"
fi
[[ -n "$SOURCE_DIR" ]] || { echo "one of --source-dir or --archive-url is required" >&2; exit 1; }
[[ -d "$SOURCE_DIR" ]] || { echo "source directory does not exist: $SOURCE_DIR" >&2; exit 1; }
[[ -f "$SOURCE_DIR/AUTORUN.INF" ]] || { echo "missing AUTORUN.INF in source directory" >&2; exit 1; }
[[ -d "$SOURCE_DIR/MICROD" ]] || { echo "missing MICROD/ in source directory" >&2; exit 1; }
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
cp -a "$SOURCE_DIR"/. "$INSTALL_DIR"/
echo "installed MicroDicom assets -> $INSTALL_DIR"
cat <<EOF
Done.
Set this config value:
iso:
microdicom_path: "$INSTALL_DIR"
EOF