Created
November 25, 2025 13:51
-
-
Save Incipiens/9817edf5c8bba53ef27bb705ce1cd89f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/sh | |
| # Proxmox VE 9.1 - LanguageTool LXC creator and installer | |
| # Written by Adam Conway 2025 | |
| set -u | |
| # ============================================================================ | |
| # COLORS & OUTPUT HELPERS | |
| # ============================================================================ | |
| GN="$(printf '\033[0;32m')" YW="$(printf '\033[0;33m')" CY="$(printf '\033[0;36m')" | |
| RD="$(printf '\033[0;31m')" BD="$(printf '\033[1m')" CL="$(printf '\033[0m')" | |
| OK="$(printf '\033[0;32m✔\033[0m')" | |
| msg() { printf "%s\n" "$*"; } | |
| info() { msg "${CY}[INFO]${CL} $*"; } | |
| warn() { msg "${YW}[WARN]${CL} $*"; } | |
| err() { msg "${RD}[ERROR]${CL} $*"; exit 1; } | |
| need() { command -v "$1" >/dev/null 2>&1 || err "Missing command: $1"; } | |
| # ============================================================================ | |
| # PREREQUISITE CHECKS | |
| # ============================================================================ | |
| need pveversion | |
| need pvesm | |
| need pct | |
| need pveam | |
| need pvesh | |
| need awk | |
| need sed | |
| # ============================================================================ | |
| # USER CONFIRMATION | |
| # ============================================================================ | |
| printf "This will create a new LanguageTool LXC Container. Proceed (y/n)? " | |
| read yn || exit 1 | |
| case "${yn:-}" in y|Y) : ;; n|N) exit 0 ;; *) err "Please answer y or n." ;; esac | |
| # ============================================================================ | |
| # TEMP FILE SETUP | |
| # ============================================================================ | |
| TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT INT TERM | |
| SETUP="$TMPDIR/languagetool_setup.sh" | |
| STFILE="$TMPDIR/stor.txt" | |
| # ============================================================================ | |
| # STORAGE SELECTION | |
| # ============================================================================ | |
| info "Scanning storage pools…" | |
| pvesm status 2>/dev/null | awk 'NR>1' > "$STFILE" | |
| [ -s "$STFILE" ] || err "No storages found. Add one in Datacenter → Storage." | |
| FIRST_STORAGE="" | |
| LINES="$(wc -l < "$STFILE" | tr -d ' ')" | |
| while IFS= read -r line; do | |
| set -- $line | |
| tag="$1"; stype="$2"; freek="$6" | |
| [ -n "$FIRST_STORAGE" ] || FIRST_STORAGE="$tag" | |
| if [ -n "${freek:-}" ] && command -v numfmt >/dev/null 2>&1; then | |
| freehr="$(printf "%s" "$freek" | numfmt --from=K --to=iec --format %.2f 2>/dev/null)B" | |
| else | |
| freehr="${freek:-?}KB" | |
| fi | |
| msg " - ${BD}$tag${CL} (type: $stype, free: $freehr)" | |
| done < "$STFILE" | |
| if [ "$LINES" -gt 1 ]; then | |
| printf "Enter storage to use [%s]: " "$FIRST_STORAGE" | |
| read STORAGE || exit 1 | |
| [ -n "${STORAGE:-}" ] || STORAGE="$FIRST_STORAGE" | |
| else | |
| STORAGE="$FIRST_STORAGE" | |
| fi | |
| info "Using storage: '$STORAGE'" | |
| # Determine storage type | |
| STYPE="$(pvesm status | awk -v S="$STORAGE" 'NR>1 && $1==S {print $2}')" | |
| [ -n "$STYPE" ] || err "Could not determine type for storage '$STORAGE'." | |
| # ============================================================================ | |
| # KERNEL MODULE (optional overlay for nesting) | |
| # ============================================================================ | |
| if ! lsmod | grep -Fq overlay 2>/dev/null; then | |
| modprobe overlay 2>/dev/null || true | |
| if ! grep -Fxq overlay /etc/modules 2>/dev/null; then | |
| printf "overlay\n" >> /etc/modules 2>/dev/null || true | |
| fi | |
| fi | |
| # ============================================================================ | |
| # CONTAINER ID | |
| # ============================================================================ | |
| CTID="$(pvesh get /cluster/nextid)" || err "Could not obtain next CTID." | |
| info "Container ID: $CTID" | |
| # ============================================================================ | |
| # TEMPLATE DOWNLOAD & PREPARATION | |
| # ============================================================================ | |
| CACHE_DIR="/var/lib/vz/template/cache" | |
| msg "${OK} ${BD}Updating LXC template list…${CL}" | |
| pveam update >/dev/null 2>&1 || true | |
| # Prefer Debian 12; accept zst/xz/tar | |
| TEMPLATE="$(pveam available 2>/dev/null \ | |
| | awk '/debian-12-standard_.*_amd64\.tar\.(zst|xz)|debian-12-standard_.*_amd64\.tar$/ {print $2}' \ | |
| | sort -t_ -k2V | tail -n 1)" | |
| [ -n "$TEMPLATE" ] || err "No Debian 12 template found." | |
| msg "${OK} ${BD}Downloading LXC template (${TEMPLATE})…${CL}" | |
| pveam download local "$TEMPLATE" >/dev/null || err "Template download failed." | |
| # Convert compressed templates to plain .tar | |
| DL_PATH="${CACHE_DIR}/${TEMPLATE}" | |
| case "$DL_PATH" in | |
| *.tar.zst) | |
| PLAIN_TAR="${DL_PATH%.zst}" | |
| msg "${OK} ${BD}Converting .zst → .tar…${CL}" | |
| zstd -d -c "$DL_PATH" > "$PLAIN_TAR" || err "zstd decompress failed" | |
| rm -f "$DL_PATH" | |
| TFILE="$PLAIN_TAR" | |
| ;; | |
| *.tar.xz) | |
| PLAIN_TAR="${DL_PATH%.xz}" | |
| msg "${OK} ${BD}Converting .xz → .tar…${CL}" | |
| xz -dc "$DL_PATH" > "$PLAIN_TAR" || err "xz decompress failed" | |
| rm -f "$DL_PATH" | |
| TFILE="$PLAIN_TAR" | |
| ;; | |
| *.tar) | |
| TFILE="$DL_PATH" | |
| ;; | |
| *) | |
| err "Unknown template extension: $DL_PATH" | |
| ;; | |
| esac | |
| # Verify the tar is readable | |
| tar -tf "$TFILE" >/dev/null || err "Resulting tar is unreadable: $TFILE" | |
| # ============================================================================ | |
| # DISK ALLOCATION & FORMATTING | |
| # ============================================================================ | |
| # Helper function: choose next free disk index for this CTID | |
| next_disk_name() { | |
| _ctid="$1"; _storage="$2" | |
| i=0 | |
| while :; do | |
| cand="vm-${_ctid}-disk-${i}" | |
| if ! pvesm list "$_storage" --vmid "$_ctid" 2>/dev/null | awk '{print $1}' | grep -q "^${_storage}:${cand}\$"; then | |
| printf "%s" "$cand"; return 0 | |
| fi | |
| i=$((i+1)) | |
| done | |
| } | |
| DISK_SIZE="64G" | |
| DISK_NAME="$(next_disk_name "$CTID" "$STORAGE")" | |
| ARCH="$(dpkg --print-architecture)" | |
| HOSTNAME="languagetool" | |
| case "$STYPE" in | |
| lvmthin|lvm) | |
| msg "${OK} ${BD}Allocating ${STYPE} volume ${DISK_NAME} (${DISK_SIZE}) on ${STORAGE}…${CL}" | |
| pvesm alloc "$STORAGE" "$CTID" "$DISK_NAME" "$DISK_SIZE" >/dev/null \ | |
| || err "pvesm alloc failed on $STORAGE" | |
| # Format the LV before pct create (prevents mount failures) | |
| DEV_PATH="$(pvesm path "${STORAGE}:${DISK_NAME}")" | |
| [ -n "$DEV_PATH" ] || err "Failed to resolve device path for ${STORAGE}:${DISK_NAME}" | |
| lvchange -ay "$DEV_PATH" >/dev/null 2>&1 || true | |
| msg "${OK} ${BD}Formatting ${DEV_PATH} as ext4 (root_owner=100000:100000)…${CL}" | |
| mkfs.ext4 -F -E root_owner=100000:100000 "$DEV_PATH" >/dev/null 2>&1 \ | |
| || err "mkfs.ext4 failed on ${DEV_PATH}" | |
| ROOTFS_SPEC="${STORAGE}:${DISK_NAME}" | |
| ;; | |
| dir|nfs) | |
| DISK_FILE="${DISK_NAME}.raw" | |
| msg "${OK} ${BD}Allocating file image ${DISK_FILE} (${DISK_SIZE}) on ${STORAGE}…${CL}" | |
| pvesm alloc "$STORAGE" "$CTID" "$DISK_FILE" "$DISK_SIZE" --format raw >/dev/null \ | |
| || err "pvesm alloc failed on $STORAGE" | |
| IMG_PATH="$(pvesm path "${STORAGE}:${CTID}/${DISK_FILE}")" | |
| [ -n "$IMG_PATH" ] || err "Failed to resolve image path" | |
| msg "${OK} ${BD}Formatting ${IMG_PATH} as ext4…${CL}" | |
| mkfs.ext4 -F "$IMG_PATH" >/dev/null 2>&1 || err "mkfs.ext4 failed" | |
| ROOTFS_SPEC="${STORAGE}:${CTID}/${DISK_FILE}" | |
| ;; | |
| zfspool|btrfs) | |
| msg "${OK} ${BD}Allocating ${STYPE} subvol ${DISK_NAME} (${DISK_SIZE}) on ${STORAGE}…${CL}" | |
| pvesm alloc "$STORAGE" "$CTID" "$DISK_NAME" "$DISK_SIZE" >/dev/null \ | |
| || err "pvesm alloc failed on $STORAGE" | |
| ROOTFS_SPEC="${STORAGE}:${DISK_NAME}" | |
| ;; | |
| *) | |
| err "Unsupported storage type '$STYPE'." | |
| ;; | |
| esac | |
| # ============================================================================ | |
| # CONTAINER CREATION | |
| # ============================================================================ | |
| msg "${OK} ${BD}Creating LXC container…${CL}" | |
| pct create "$CTID" "$TFILE" \ | |
| -arch "$ARCH" \ | |
| -features nesting=1 \ | |
| -hostname "$HOSTNAME" \ | |
| -net0 name=eth0,bridge=vmbr0,ip=dhcp \ | |
| -onboot 1 \ | |
| -cores 2 \ | |
| -memory 4096 \ | |
| -unprivileged 1 \ | |
| -rootfs "$ROOTFS_SPEC" >/dev/null || err "pct create failed." | |
| # ============================================================================ | |
| # TIMEZONE SYNC | |
| # ============================================================================ | |
| MOUNT_LINE="$(pct mount "$CTID" 2>/dev/null || true)" | |
| MOUNT_DIR="$(printf "%s" "$MOUNT_LINE" | awk -F"'" '/mounted/ {print $2}')" | |
| if [ -n "$MOUNT_DIR" ] && [ -e /etc/localtime ]; then | |
| ln -fs "$(readlink /etc/localtime)" "$MOUNT_DIR/etc/localtime" 2>/dev/null || true | |
| pct unmount "$CTID" >/dev/null 2>&1 || true | |
| fi | |
| # ============================================================================ | |
| # START CONTAINER | |
| # ============================================================================ | |
| msg "${OK} ${BD}Starting LXC container…${CL}" | |
| pct start "$CTID" >/dev/null || err "pct start failed." | |
| # ============================================================================ | |
| # IN-CONTAINER SETUP SCRIPT | |
| # ============================================================================ | |
| cat > "$SETUP" <<'EOF' | |
| #!/bin/sh | |
| set -u | |
| OK="$(printf '\033[0;32m✔\033[0m')"; BD="$(printf '\033[1m')"; CL="$(printf '\033[0m')" | |
| msg(){ printf "%s\n" "$*"; } | |
| # --- Locale setup --- | |
| msg "${OK} ${BD}Setting up container OS…${CL}" | |
| if ! grep -q '^en_US\.UTF-8 UTF-8' /etc/locale.gen 2>/dev/null; then | |
| if grep -q '^# *en_US\.UTF-8 UTF-8' /etc/locale.gen 2>/dev/null; then | |
| sed -i 's/^# *en_US\.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen || true | |
| else | |
| printf "en_US.UTF-8 UTF-8\n" >> /etc/locale.gen || true | |
| fi | |
| fi | |
| locale-gen >/dev/null 2>&1 || true | |
| update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || true | |
| # --- System updates --- | |
| apt-get update -qq || true | |
| DEBIAN_FRONTEND=noninteractive apt-get -y -qq upgrade || true | |
| # --- Install prerequisites --- | |
| msg "${OK} ${BD}Installing prerequisites…${CL}" | |
| DEBIAN_FRONTEND=noninteractive apt-get -y -qq install \ | |
| default-jre-headless unzip wget ca-certificates apt-transport-https \ | |
| hunspell hunspell-de-de hunspell-en-us || exit 1 | |
| # --- Install LanguageTool --- | |
| msg "${OK} ${BD}Installing LanguageTool Server…${CL}" | |
| cd /tmp || exit 1 | |
| wget -q https://languagetool.org/download/LanguageTool-stable.zip || exit 1 | |
| unzip -q LanguageTool-stable.zip || exit 1 | |
| rm -rf /opt/LanguageTool | |
| mv LanguageTool-*.*/ /opt/LanguageTool || exit 1 | |
| rm -f LanguageTool-stable.zip | |
| # --- Download ngrams --- | |
| # These are the best ngrams available, run from an SSD or with enough RAM, otherwise it'll be slow | |
| mkdir -p /opt/LanguageTool/ngrams | |
| wget -q https://languagetool.org/download/ngram-data/ngrams-de-20150819.zip | |
| wget -q https://languagetool.org/download/ngram-data/ngrams-en-20150817.zip | |
| unzip -q ngrams-de-20150819.zip -d /opt/LanguageTool/ngrams | |
| unzip -q ngrams-en-20150817.zip -d /opt/LanguageTool/ngrams | |
| rm -f ngrams-de-20150819.zip ngrams-en-20150817.zip | |
| # --- Configure LanguageTool --- | |
| cat > /opt/LanguageTool/languagetool.cfg <<'CFG' | |
| languageModel=/opt/LanguageTool/ngrams | |
| CFG | |
| # --- Create systemd service --- | |
| cat > /etc/systemd/system/languagetool.service <<'SVC' | |
| [Unit] | |
| Description=LanguageTool HTTP server | |
| After=network-online.target | |
| Wants=network-online.target | |
| [Service] | |
| Type=simple | |
| Environment=JAVA_OPTS=-Xms512m -Xmx3g | |
| WorkingDirectory=/opt/LanguageTool | |
| ExecStart=/usr/bin/java $JAVA_OPTS -cp /opt/LanguageTool/languagetool-server.jar org.languagetool.server.HTTPServer --public --port 8081 --allow-origin "*" --config /opt/LanguageTool/languagetool.cfg | |
| User=nobody | |
| Group=nogroup | |
| Restart=on-failure | |
| RestartSec=5 | |
| [Install] | |
| WantedBy=multi-user.target | |
| SVC | |
| systemctl daemon-reload | |
| systemctl enable --now languagetool | |
| # --- Quiet login banner --- | |
| rm -f /etc/motd /etc/update-motd.d/10-uname 2>/dev/null || true | |
| touch /root/.hushlogin | |
| # --- Autologin on console (cover both common getty units) --- | |
| for UNIT in "container-getty@1.service" "getty@tty1.service"; do | |
| DIR="/etc/systemd/system/${UNIT}.d" | |
| mkdir -p "$DIR" | |
| cat > "${DIR}/override.conf" <<'OVR' | |
| [Service] | |
| ExecStart= | |
| ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud %I 115200,38400,9600 $TERM | |
| OVR | |
| done | |
| # Ensure agetty path exists (fallback if not in /sbin) | |
| if [ ! -x /sbin/agetty ] && command -v agetty >/dev/null 2>&1; then | |
| AGETTY_PATH="$(command -v agetty | sed 's|/|\\/|g')" | |
| sed -i "s|/sbin/agetty|${AGETTY_PATH}|g" \ | |
| /etc/systemd/system/container-getty@1.service.d/override.conf \ | |
| /etc/systemd/system/getty@tty1.service.d/override.conf 2>/dev/null || true | |
| fi | |
| systemctl daemon-reload | |
| systemctl restart container-getty@1.service 2>/dev/null || true | |
| systemctl restart getty@tty1.service 2>/dev/null || true | |
| EOF | |
| # ============================================================================ | |
| # EXECUTE IN-CONTAINER SETUP | |
| # ============================================================================ | |
| chmod 755 "$SETUP" | |
| pct push "$CTID" "$SETUP" /languagetool_setup.sh -perms 755 >/dev/null || err "pct push failed." | |
| pct exec "$CTID" -- sh /languagetool_setup.sh || err "In-container setup failed." | |
| # ============================================================================ | |
| # COMPLETION & IP DISPLAY | |
| # ============================================================================ | |
| IP="$(pct exec "$CTID" -- sh -lc "ip -o -4 addr show dev eth0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1" | tr -d '\r' || true)" | |
| if [ -n "${IP:-}" ]; then | |
| info "LanguageTool container $CTID is up at: http://$IP:8081/v2" | |
| else | |
| warn "Could not detect IP on eth0. Check DHCP/bridge config." | |
| fi | |
| msg "${OK} ${BD}All done!${CL}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment