438 lines
17 KiB
Bash
Executable File
438 lines
17 KiB
Bash
Executable File
#!/bin/bash
|
|
# freeipa-image-builder.sh — build a FreeIPA server image for multiple targets
|
|
#
|
|
# Targets (TUI chooser):
|
|
# docker Build Docker/Podman image and optionally push to a registry
|
|
# lxc Export rootfs as a Proxmox/LXC .tar.zst CT template
|
|
# proxmox-vm Customize a Rocky/Fedora cloud image → QCOW2 (requires virt-customize)
|
|
# oci-archive Export OCI tarball via skopeo (for air-gapped import)
|
|
#
|
|
# Keycloak:
|
|
# Optionally generates a docker-compose.yml that includes the full
|
|
# FreeIPA + Keycloak + PostgreSQL constellation and a pre-configured
|
|
# keycloak-configure.sh for post-start LDAP federation setup.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
IMAGE_SRC="$SCRIPT_DIR/../../FreeipaAnsible/image"
|
|
|
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'; CYAN='\033[0;36m'; MAGENTA='\033[0;35m'; NC='\033[0m'
|
|
|
|
log() { echo -e "${GREEN}[+]${NC} $*"; }
|
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
|
error() { echo -e "${RED}[✗]${NC} $*" >&2; }
|
|
info() { echo -e "${CYAN}[i]${NC} $*"; }
|
|
section() { echo -e "\n${BLUE}━━━ $* ━━━${NC}"; }
|
|
ask() { printf "${MAGENTA}[?]${NC} %s " "$*"; }
|
|
|
|
[[ $EUID -eq 0 ]] && { error "Run as your normal user (not root)."; exit 1; }
|
|
|
|
# ─── Detect container engine ──────────────────────────────────────────────────
|
|
if command -v podman &>/dev/null; then
|
|
ENGINE="podman"
|
|
elif command -v docker &>/dev/null; then
|
|
ENGINE="docker"
|
|
else
|
|
error "Neither podman nor docker found. Install one first."
|
|
exit 1
|
|
fi
|
|
info "Container engine: $ENGINE"
|
|
|
|
# ─── dialog theme ─────────────────────────────────────────────────────────────
|
|
TMP_D="$(mktemp -d)"; trap 'rm -rf "$TMP_D"' EXIT
|
|
BACKTITLE="FreeIPA Image Builder"
|
|
export DIALOGRC="$TMP_D/dialogrc"
|
|
cat > "$DIALOGRC" <<'EOF'
|
|
use_shadow = ON
|
|
use_colors = ON
|
|
screen_color = (BLACK,BLACK,ON)
|
|
title_color = (MAGENTA,BLACK,ON)
|
|
border_color = (MAGENTA,BLACK,ON)
|
|
button_active_color = (BLACK,MAGENTA,ON)
|
|
button_inactive_color = (WHITE,BLACK,OFF)
|
|
menubox_color = (WHITE,BLACK,OFF)
|
|
menubox_border_color = (MAGENTA,BLACK,ON)
|
|
item_color = (WHITE,BLACK,OFF)
|
|
item_selected_color = (BLACK,MAGENTA,ON)
|
|
tag_color = (CYAN,BLACK,ON)
|
|
tag_selected_color = (BLACK,CYAN,ON)
|
|
check_color = (WHITE,BLACK,OFF)
|
|
check_selected_color = (BLACK,MAGENTA,ON)
|
|
uarrow_color = (MAGENTA,BLACK,ON)
|
|
darrow_color = (MAGENTA,BLACK,ON)
|
|
EOF
|
|
|
|
command -v dialog &>/dev/null || { sudo pacman -S --noconfirm dialog; }
|
|
|
|
# ─── Target chooser ───────────────────────────────────────────────────────────
|
|
TARGET=$(dialog --backtitle "$BACKTITLE" \
|
|
--title " Select Target Format " \
|
|
--menu "Choose the output format:" 16 70 4 \
|
|
"docker" "Docker / Podman image (local + optional registry push)" \
|
|
"lxc" "LXC template (.tar.zst rootfs, generic LXC/LXD)" \
|
|
"proxmox-lxc" "Proxmox LXC CT template + conf + optional upload" \
|
|
"oci-archive" "OCI archive (skopeo tarball for air-gapped import)" \
|
|
3>&1 1>&2 2>&3) || { clear; echo "Aborted."; exit 0; }
|
|
|
|
# ─── Keycloak? ───────────────────────────────────────────────────────────────
|
|
WITH_KEYCLOAK=false
|
|
dialog --backtitle "$BACKTITLE" \
|
|
--title " Keycloak Integration " \
|
|
--yesno "\nInclude Keycloak in the output?\n\n\
|
|
Adds PostgreSQL + Keycloak to the compose stack and generates\n\
|
|
keycloak-configure.sh for LDAP federation post-start setup." 11 64 \
|
|
&& WITH_KEYCLOAK=true || true
|
|
|
|
# ─── FreeIPA config ───────────────────────────────────────────────────────────
|
|
section "FreeIPA configuration"
|
|
info "These values are embedded in the .env template and compose file."
|
|
info "Override them at runtime via environment variables."
|
|
echo
|
|
|
|
ask "IPA Domain (e.g. corp.example.com):"; read -r IPA_DOMAIN
|
|
[[ -z "$IPA_DOMAIN" ]] && { error "IPA_DOMAIN is required."; exit 1; }
|
|
|
|
GUESSED_HOSTNAME="ipa.$IPA_DOMAIN"
|
|
ask "Server FQDN [$GUESSED_HOSTNAME]:"; read -r I; IPA_HOSTNAME="${I:-$GUESSED_HOSTNAME}"
|
|
|
|
GUESSED_REALM="${IPA_DOMAIN^^}"
|
|
ask "Kerberos Realm [$GUESSED_REALM]:"; read -r I; IPA_REALM="${I:-$GUESSED_REALM}"
|
|
|
|
ask "Setup integrated DNS? [y/N]:"; read -r I
|
|
IPA_SETUP_DNS=false; [[ "${I,,}" == "y"* ]] && IPA_SETUP_DNS=true
|
|
|
|
IMAGE_TAG="freeipa-server:${IPA_DOMAIN//./-}"
|
|
ask "Image tag [$IMAGE_TAG]:"; read -r I; IMAGE_TAG="${I:-$IMAGE_TAG}"
|
|
|
|
# Output directory
|
|
DEFAULT_OUT="$HOME/freeipa-image-output"
|
|
ask "Output directory [$DEFAULT_OUT]:"; read -r I; OUTPUT_DIR="${I:-$DEFAULT_OUT}"
|
|
|
|
# Keycloak config (if selected)
|
|
KC_HOSTNAME="keycloak.$IPA_DOMAIN"
|
|
KC_REALM="${IPA_DOMAIN%%.*}"
|
|
if [[ "$WITH_KEYCLOAK" == true ]]; then
|
|
echo
|
|
ask "Keycloak hostname [$KC_HOSTNAME]:"; read -r I; KC_HOSTNAME="${I:-$KC_HOSTNAME}"
|
|
ask "Keycloak realm name [$KC_REALM]:"; read -r I; KC_REALM="${I:-$KC_REALM}"
|
|
fi
|
|
|
|
# Proxmox LXC extra config
|
|
PVE_HOST=""; PVE_VMID="100"; PVE_STORAGE="local"; PVE_BRIDGE="vmbr0"
|
|
PVE_MEMORY="4096"; PVE_CORES="4"; PVE_DISK_SIZE="20"
|
|
if [[ "$TARGET" == "proxmox-lxc" ]]; then
|
|
echo
|
|
ask "Proxmox host (blank to skip upload):"; read -r PVE_HOST
|
|
ask "Container ID [100]:"; read -r I; PVE_VMID="${I:-100}"
|
|
ask "Storage for rootfs [local-lvm]:"; read -r I; PVE_STORAGE="${I:-local-lvm}"
|
|
ask "Network bridge [vmbr0]:"; read -r I; PVE_BRIDGE="${I:-vmbr0}"
|
|
ask "Memory MB [4096]:"; read -r I; PVE_MEMORY="${I:-4096}"
|
|
ask "CPU cores [4]:"; read -r I; PVE_CORES="${I:-4}"
|
|
ask "Disk size GB [20]:"; read -r I; PVE_DISK_SIZE="${I:-20}"
|
|
fi
|
|
|
|
# ─── Confirm ──────────────────────────────────────────────────────────────────
|
|
echo
|
|
info "──────────────────────────────────────"
|
|
printf " Target: %s\n" "$TARGET"
|
|
printf " Domain: %s\n" "$IPA_DOMAIN"
|
|
printf " FQDN: %s\n" "$IPA_HOSTNAME"
|
|
printf " Realm: %s\n" "$IPA_REALM"
|
|
printf " Image tag: %s\n" "$IMAGE_TAG"
|
|
printf " Keycloak: %s\n" "$WITH_KEYCLOAK"
|
|
printf " Output: %s\n" "$OUTPUT_DIR"
|
|
info "──────────────────────────────────────"
|
|
echo
|
|
ask "Proceed? [y/N]:"; read -r CONFIRM
|
|
[[ "${CONFIRM,,}" != "y"* ]] && { echo "Aborted."; exit 0; }
|
|
|
|
mkdir -p "$OUTPUT_DIR"
|
|
|
|
# ─── Step 1: Always build the container image ─────────────────────────────────
|
|
section "Building container image ($ENGINE)"
|
|
$ENGINE build \
|
|
--tag "$IMAGE_TAG" \
|
|
--label "ipa.domain=$IPA_DOMAIN" \
|
|
--label "ipa.realm=$IPA_REALM" \
|
|
"$IMAGE_SRC"
|
|
log "Image built: $IMAGE_TAG"
|
|
|
|
# ─── Step 2: Target-specific export ──────────────────────────────────────────
|
|
|
|
case "$TARGET" in
|
|
|
|
docker)
|
|
section "Docker/Podman — local image ready"
|
|
log "Image '$IMAGE_TAG' is in the local $ENGINE store."
|
|
|
|
ask "Push to a registry? [y/N]:"; read -r I
|
|
if [[ "${I,,}" == "y"* ]]; then
|
|
ask "Registry image name (e.g. registry.example.com/freeipa-server:latest):"; read -r REG_TAG
|
|
if [[ -n "$REG_TAG" ]]; then
|
|
$ENGINE tag "$IMAGE_TAG" "$REG_TAG"
|
|
$ENGINE push "$REG_TAG"
|
|
log "Pushed: $REG_TAG"
|
|
fi
|
|
fi
|
|
;;
|
|
|
|
lxc)
|
|
section "LXC / Proxmox CT template"
|
|
LXC_ARCHIVE="$OUTPUT_DIR/freeipa-server-lxc.tar.zst"
|
|
|
|
info "Creating temporary container to export rootfs..."
|
|
TMP_CTR="freeipa-lxc-export-$$"
|
|
$ENGINE create --name "$TMP_CTR" "$IMAGE_TAG" /bin/true
|
|
|
|
info "Exporting rootfs (this may take a minute)..."
|
|
if command -v zstd &>/dev/null; then
|
|
$ENGINE export "$TMP_CTR" | zstd -T0 -o "$LXC_ARCHIVE"
|
|
else
|
|
warn "zstd not found — falling back to gzip (.tar.gz)"
|
|
LXC_ARCHIVE="${LXC_ARCHIVE%.tar.zst}.tar.gz"
|
|
$ENGINE export "$TMP_CTR" | gzip -9 > "$LXC_ARCHIVE"
|
|
fi
|
|
$ENGINE rm "$TMP_CTR" &>/dev/null
|
|
log "LXC template: $LXC_ARCHIVE"
|
|
|
|
# Proxmox import instructions
|
|
cat > "$OUTPUT_DIR/lxc-import-instructions.txt" <<LXCTXT
|
|
Proxmox LXC import instructions
|
|
================================
|
|
Upload template to Proxmox:
|
|
scp $LXC_ARCHIVE root@proxmox:/var/lib/vz/template/cache/
|
|
|
|
Create container (via Proxmox web UI or CLI):
|
|
pct create <VMID> local:vztmpl/$(basename "$LXC_ARCHIVE") \\
|
|
--hostname $IPA_HOSTNAME \\
|
|
--memory 2048 --cores 2 \\
|
|
--net0 name=eth0,bridge=vmbr0,ip=dhcp \\
|
|
--ostype fedora --unprivileged 0
|
|
|
|
NOTE: FreeIPA requires a privileged LXC container (unprivileged=0).
|
|
Add to /etc/pve/lxc/<VMID>.conf:
|
|
lxc.apparmor.profile: unconfined
|
|
lxc.cap.drop:
|
|
|
|
Set FreeIPA env vars before first start:
|
|
pct exec <VMID> -- bash -c 'cat >> /etc/environment <<EOF
|
|
IPA_DOMAIN=$IPA_DOMAIN
|
|
IPA_REALM=$IPA_REALM
|
|
IPA_ADMIN_PASSWORD=YourAdminPassword
|
|
IPA_DM_PASSWORD=YourDMPassword
|
|
EOF'
|
|
|
|
Start and watch first-boot:
|
|
pct start <VMID>
|
|
pct exec <VMID> -- journalctl -f -u ipa-first-boot
|
|
LXCTXT
|
|
log "Instructions: $OUTPUT_DIR/lxc-import-instructions.txt"
|
|
;;
|
|
|
|
proxmox-lxc)
|
|
section "Proxmox LXC CT template"
|
|
TEMPLATE_NAME="freeipa-server-proxmox-lxc.tar.zst"
|
|
TEMPLATE_PATH="$OUTPUT_DIR/$TEMPLATE_NAME"
|
|
|
|
info "Creating temporary container to export rootfs..."
|
|
TMP_CTR="freeipa-pve-export-$$"
|
|
$ENGINE create --name "$TMP_CTR" "$IMAGE_TAG" /bin/true
|
|
|
|
info "Exporting rootfs..."
|
|
if command -v zstd &>/dev/null; then
|
|
$ENGINE export "$TMP_CTR" | zstd -T0 -o "$TEMPLATE_PATH"
|
|
else
|
|
warn "zstd not found — falling back to gzip (.tar.gz)"
|
|
TEMPLATE_NAME="freeipa-server-proxmox-lxc.tar.gz"
|
|
TEMPLATE_PATH="$OUTPUT_DIR/$TEMPLATE_NAME"
|
|
$ENGINE export "$TMP_CTR" | gzip -9 > "$TEMPLATE_PATH"
|
|
fi
|
|
$ENGINE rm "$TMP_CTR" &>/dev/null
|
|
log "Template: $TEMPLATE_PATH"
|
|
|
|
# Generate Proxmox CT config file
|
|
CT_CONF="$OUTPUT_DIR/pve-ct-${PVE_VMID}.conf"
|
|
cat > "$CT_CONF" <<CONFEOF
|
|
# Proxmox LXC config for FreeIPA server — copy to /etc/pve/lxc/${PVE_VMID}.conf
|
|
# FreeIPA requires a privileged container with unconfined AppArmor and full cgroup access.
|
|
arch: amd64
|
|
cores: $PVE_CORES
|
|
hostname: ${IPA_HOSTNAME%%.*}
|
|
memory: $PVE_MEMORY
|
|
net0: name=eth0,bridge=$PVE_BRIDGE,firewall=0,ip=dhcp
|
|
ostype: fedora
|
|
rootfs: $PVE_STORAGE:vm-${PVE_VMID}-disk-0,size=${PVE_DISK_SIZE}G
|
|
swap: 512
|
|
unprivileged: 0
|
|
lxc.apparmor.profile: unconfined
|
|
lxc.cap.drop:
|
|
lxc.mount.auto: proc:rw sys:rw cgroup:rw
|
|
lxc.cgroup2.devices.allow: a
|
|
CONFEOF
|
|
log "CT config: $CT_CONF"
|
|
|
|
# Generate setup guide
|
|
cat > "$OUTPUT_DIR/proxmox-lxc-setup.txt" <<GUIDEOF
|
|
Proxmox LXC setup guide — FreeIPA server
|
|
==========================================
|
|
Template: $TEMPLATE_NAME
|
|
CT ID: $PVE_VMID
|
|
Storage: $PVE_STORAGE
|
|
Bridge: $PVE_BRIDGE
|
|
|
|
── Step 1: Upload template ─────────────────────────────────
|
|
scp $TEMPLATE_PATH root@<proxmox-host>:/var/lib/vz/template/cache/
|
|
|
|
── Step 2: Create the container ────────────────────────────
|
|
pct create $PVE_VMID local:vztmpl/$TEMPLATE_NAME \\
|
|
--hostname ${IPA_HOSTNAME%%.*} \\
|
|
--memory $PVE_MEMORY --cores $PVE_CORES \\
|
|
--rootfs $PVE_STORAGE:${PVE_DISK_SIZE} \\
|
|
--net0 name=eth0,bridge=$PVE_BRIDGE,ip=dhcp \\
|
|
--ostype fedora --unprivileged 0 \\
|
|
--features nesting=1
|
|
|
|
── Step 3: Apply required LXC options ──────────────────────
|
|
# FreeIPA needs unconfined AppArmor and full cgroup access.
|
|
# Copy the generated config or append these lines:
|
|
cat >> /etc/pve/lxc/$PVE_VMID.conf <<EOF
|
|
lxc.apparmor.profile: unconfined
|
|
lxc.cap.drop:
|
|
lxc.mount.auto: proc:rw sys:rw cgroup:rw
|
|
lxc.cgroup2.devices.allow: a
|
|
EOF
|
|
|
|
── Step 4: Set FreeIPA configuration ───────────────────────
|
|
pct start $PVE_VMID
|
|
pct exec $PVE_VMID -- bash -c 'cat >> /etc/environment <<EOF
|
|
IPA_DOMAIN=$IPA_DOMAIN
|
|
IPA_REALM=$IPA_REALM
|
|
IPA_HOSTNAME=$IPA_HOSTNAME
|
|
IPA_ADMIN_PASSWORD=ChangeMe123!
|
|
IPA_DM_PASSWORD=ChangeMe456!
|
|
IPA_SETUP_DNS=false
|
|
EOF'
|
|
pct stop $PVE_VMID
|
|
|
|
── Step 5: Start and watch first-boot ──────────────────────
|
|
pct start $PVE_VMID
|
|
pct exec $PVE_VMID -- journalctl -fu ipa-first-boot
|
|
# First-boot installs FreeIPA (~10 min). Done when you see:
|
|
# "ipa-first-boot complete"
|
|
|
|
── Step 6 (optional): Keycloak stack ───────────────────────
|
|
# Run the Keycloak constellation on a separate CT or VM,
|
|
# then configure LDAP federation:
|
|
# IPA_SERVER=$IPA_HOSTNAME KC_ADMIN_PASSWORD=<pw> \\
|
|
# IPA_DOMAIN=$IPA_DOMAIN IPA_DM_PASSWORD=<pw> \\
|
|
# ./keycloak-configure.sh
|
|
GUIDEOF
|
|
log "Setup guide: $OUTPUT_DIR/proxmox-lxc-setup.txt"
|
|
|
|
# Optional: upload to Proxmox host
|
|
if [[ -n "$PVE_HOST" ]]; then
|
|
section "Uploading template to $PVE_HOST"
|
|
scp "$TEMPLATE_PATH" "root@${PVE_HOST}:/var/lib/vz/template/cache/"
|
|
scp "$CT_CONF" "root@${PVE_HOST}:/etc/pve/lxc/${PVE_VMID}.conf"
|
|
log "Template uploaded to $PVE_HOST"
|
|
info "Next: pct create $PVE_VMID local:vztmpl/$TEMPLATE_NAME ..."
|
|
info " (see $OUTPUT_DIR/proxmox-lxc-setup.txt for full command)"
|
|
fi
|
|
;;
|
|
|
|
oci-archive)
|
|
section "OCI archive (skopeo)"
|
|
|
|
if ! command -v skopeo &>/dev/null; then
|
|
error "skopeo not found. Install: sudo pacman -S skopeo"
|
|
exit 1
|
|
fi
|
|
|
|
OCI_ARCHIVE="$OUTPUT_DIR/freeipa-server-oci.tar"
|
|
info "Exporting OCI archive..."
|
|
skopeo copy \
|
|
"${ENGINE}-daemon:${IMAGE_TAG}" \
|
|
"oci-archive:${OCI_ARCHIVE}:latest"
|
|
log "OCI archive: $OCI_ARCHIVE"
|
|
|
|
info "Import on air-gapped host:"
|
|
info " skopeo copy oci-archive:freeipa-server-oci.tar docker-daemon:freeipa-server:latest"
|
|
info " # or for podman:"
|
|
info " skopeo copy oci-archive:freeipa-server-oci.tar containers-storage:freeipa-server:latest"
|
|
;;
|
|
|
|
esac
|
|
|
|
# ─── Generate compose + .env for all targets ─────────────────────────────────
|
|
section "Generating deployment files → $OUTPUT_DIR"
|
|
|
|
cp "$IMAGE_SRC/docker-compose.yml" "$OUTPUT_DIR/docker-compose.yml"
|
|
cp "$IMAGE_SRC/keycloak-configure.sh" "$OUTPUT_DIR/keycloak-configure.sh"
|
|
chmod +x "$OUTPUT_DIR/keycloak-configure.sh"
|
|
|
|
# Patch compose to reference the built image tag instead of building locally
|
|
sed -i "s|image: freeipa-server:local|image: $IMAGE_TAG|" "$OUTPUT_DIR/docker-compose.yml"
|
|
|
|
if [[ "$WITH_KEYCLOAK" == false ]]; then
|
|
# Comment out Keycloak and postgres services
|
|
python3 - <<PYEOF "$OUTPUT_DIR/docker-compose.yml"
|
|
import sys, re
|
|
path = sys.argv[1]
|
|
with open(path) as f: content = f.read()
|
|
# Add a note but keep the file intact so it's easy to re-enable
|
|
header = "# Keycloak disabled at build time. Remove the '# ' prefixes below to enable.\n"
|
|
content = re.sub(r'(\n (postgres|keycloak):\n)', lambda m: '\n # ' + m.group(0).lstrip('\n'), content)
|
|
with open(path, 'w') as f: f.write(content)
|
|
PYEOF
|
|
fi
|
|
|
|
# Generate .env with current values as defaults
|
|
cat > "$OUTPUT_DIR/.env" <<ENVEOF
|
|
# Generated by freeipa-image-builder.sh — edit before use
|
|
IPA_HOSTNAME=$IPA_HOSTNAME
|
|
IPA_DOMAIN=$IPA_DOMAIN
|
|
IPA_REALM=$IPA_REALM
|
|
IPA_ADMIN_PASSWORD=ChangeMe123!
|
|
IPA_DM_PASSWORD=ChangeMe456!
|
|
IPA_SETUP_DNS=$IPA_SETUP_DNS
|
|
IPA_DNS_FORWARDER=
|
|
IPA_SETUP_KRA=false
|
|
|
|
KC_HOSTNAME=$KC_HOSTNAME
|
|
KC_REALM=$KC_REALM
|
|
KC_ADMIN=admin
|
|
KC_ADMIN_PASSWORD=ChangeMe789!
|
|
KC_DB_PASSWORD=ChangeMe000!
|
|
|
|
IPA_BIND_DN=cn=Directory Manager
|
|
IPA_BIND_PASSWORD=
|
|
IPA_USE_LDAPS=false
|
|
ENVEOF
|
|
log ".env (edit passwords before use)"
|
|
log "docker-compose.yml"
|
|
log "keycloak-configure.sh"
|
|
|
|
# ─── Final summary ────────────────────────────────────────────────────────────
|
|
section "Done"
|
|
cat <<EOF
|
|
|
|
Image: $IMAGE_TAG
|
|
Target: $TARGET
|
|
Output: $OUTPUT_DIR/
|
|
|
|
Quick start:
|
|
cd $OUTPUT_DIR
|
|
\$EDITOR .env # set real passwords
|
|
$ENGINE compose up -d$([ "$TARGET" == "docker" ] && echo "" || echo " freeipa")
|
|
$ENGINE compose logs -f freeipa # watch first-boot (~10 min)
|
|
$([ "$WITH_KEYCLOAK" == "true" ] && cat <<KC
|
|
# After FreeIPA is healthy:
|
|
KC_ADMIN_PASSWORD=<pw> IPA_SERVER=$IPA_HOSTNAME \\
|
|
IPA_DOMAIN=$IPA_DOMAIN IPA_DM_PASSWORD=<pw> \\
|
|
./keycloak-configure.sh
|
|
KC
|
|
)
|
|
EOF
|