diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..99bb78cc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +db +cert +*.log +Dockerfile +docker-compose.yml +.tmp +.idea +.vscode +LICENSE +README.* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8fa4eeb0..69b9c69d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# shared volume +geodata/ + # Ignore editor and IDE settings .idea/ .vscode/ diff --git a/DockerEntrypoint.sh b/DockerEntrypoint.sh index 7511d2ea..455c3211 100644 --- a/DockerEntrypoint.sh +++ b/DockerEntrypoint.sh @@ -1,7 +1,28 @@ #!/bin/sh +set -eu + +: "${MAX_GEODATA_DIR_WAIT:=30}" +: "${WAIT_INTERVAL:=10}" +: "${GEODATA_DIR:?GEODATA_DIR is required}" + +FINISH_FILE="$GEODATA_DIR/cron-job-finished.txt" +ELAPSED=0 + +while [ ! -f "$FINISH_FILE" ] && [ "$ELAPSED" -lt "$MAX_GEODATA_DIR_WAIT" ]; do + echo "Waiting for geodata initialization... ($ELAPSED/$MAX_GEODATA_DIR_WAIT seconds)" + sleep $WAIT_INTERVAL + ELAPSED=$((ELAPSED + WAIT_INTERVAL)) +done + +if [ ! -f "$FINISH_FILE" ]; then + echo "ERROR: Geodata initialization timed out after $MAX_GEODATA_DIR_WAIT seconds" + echo "Container startup aborted." + exit 1 +fi + # Start fail2ban -[ $XUI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start +[ "$XUI_ENABLE_FAIL2BAN" = "true" ] && fail2ban-client -x start # Run x-ui exec /app/x-ui diff --git a/DockerInit.sh b/DockerInit.sh deleted file mode 100755 index debfbbb8..00000000 --- a/DockerInit.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/sh -case $1 in - amd64) - ARCH="64" - FNAME="amd64" - ;; - i386) - ARCH="32" - FNAME="i386" - ;; - armv8 | arm64 | aarch64) - ARCH="arm64-v8a" - FNAME="arm64" - ;; - armv7 | arm | arm32) - ARCH="arm32-v7a" - FNAME="arm32" - ;; - armv6) - ARCH="arm32-v6" - FNAME="armv6" - ;; - *) - ARCH="64" - FNAME="amd64" - ;; -esac -mkdir -p build/bin -cd build/bin -curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-linux-${ARCH}.zip" -unzip "Xray-linux-${ARCH}.zip" -rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat -mv xray "xray-linux-${FNAME}" -curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat -curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat -curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat -curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat -curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat -curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat -cd ../../ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ec7a6e7b..ab861c8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,40 +2,40 @@ # Stage: Builder # ======================================================== FROM golang:1.25-alpine AS builder -WORKDIR /app -ARG TARGETARCH -RUN apk --no-cache --update add \ +WORKDIR /app + +RUN apk add --no-cache \ build-base \ - gcc \ - curl \ - unzip + gcc + +# docker CACHE +COPY go.mod go.sum ./ +RUN go mod download COPY . . ENV CGO_ENABLED=1 ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE" RUN go build -ldflags "-w -s" -o build/x-ui main.go -RUN ./DockerInit.sh "$TARGETARCH" # ======================================================== # Stage: Final Image of 3x-ui # ======================================================== FROM alpine -ENV TZ=Asia/Tehran + WORKDIR /app -RUN apk add --no-cache --update \ +RUN apk add --no-cache \ ca-certificates \ tzdata \ fail2ban \ bash +COPY DockerEntrypoint.sh /app/ COPY --from=builder /app/build/ /app/ -COPY --from=builder /app/DockerEntrypoint.sh /app/ COPY --from=builder /app/x-ui.sh /usr/bin/x-ui - # Configure fail2ban RUN rm -f /etc/fail2ban/jail.d/alpine-ssh.conf \ && cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local \ @@ -51,5 +51,5 @@ RUN chmod +x \ ENV XUI_ENABLE_FAIL2BAN="true" EXPOSE 2053 VOLUME [ "/etc/x-ui" ] -CMD [ "./x-ui" ] + ENTRYPOINT [ "/app/DockerEntrypoint.sh" ] diff --git a/docker-compose.yml b/docker-compose.yml index 198df198..0fa748c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,57 @@ services: - 3xui: + 3x-ui: build: context: . - dockerfile: ./Dockerfile container_name: 3xui_app - # hostname: yourhostname <- optional volumes: - $PWD/db/:/etc/x-ui/ - $PWD/cert/:/root/cert/ + - $PWD/geodata/:/app/bin environment: + TZ: "Asia/Tehran" XRAY_VMESS_AEAD_FORCED: "false" XUI_ENABLE_FAIL2BAN: "true" + GEODATA_DIR: "/app/bin" tty: true network_mode: host restart: unless-stopped + depends_on: + - geodata-cron + + docker-proxy: + image: tecnativa/docker-socket-proxy + container_name: docker_proxy + restart: unless-stopped + environment: + - CONTAINERS=1 + - POST=1 + - ALLOW_RESTARTS=1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - docker-internal + + geodata-cron: + build: + context: docker-cron-runner + args: + XRAY_VERSION: "${XRAY_VERSION:-v25.10.15}" + XRAY_BUILD_DIR: "/app/xray" + container_name: geodata_cron + restart: unless-stopped + depends_on: + - docker-proxy + environment: + TZ: "UTC" + DOCKER_PROXY_URL: "http://docker-proxy:2375" + TARGET_CONTAINER_NAME: "3xui_app" + CRON_SCHEDULE: "0 */6 * * *" + SHARED_VOLUME_PATH: "/app/bin" + volumes: + - $PWD/geodata/:/app/bin/ + networks: + - docker-internal + +networks: + docker-internal: + driver: bridge \ No newline at end of file diff --git a/docker-cron-runner/Dockerfile b/docker-cron-runner/Dockerfile new file mode 100644 index 00000000..224dca41 --- /dev/null +++ b/docker-cron-runner/Dockerfile @@ -0,0 +1,28 @@ +FROM alpine:3.20 + +ARG TARGETARCH +ARG XRAY_VERSION +ARG XRAY_BUILD_DIR + +WORKDIR /app + +RUN apk add --no-cache \ + wget \ + unzip \ + curl \ + bash \ + ca-certificates \ + tzdata + + +COPY xray-tools.sh entrypoint.sh cron-job-script.sh ./ + +RUN chmod +x /app/xray-tools.sh /app/entrypoint.sh /app/cron-job-script.sh \ + && mkdir -p "$XRAY_BUILD_DIR" \ + && ./xray-tools.sh install_xray_core "$TARGETARCH" "$XRAY_BUILD_DIR" "$XRAY_VERSION" \ + && ./xray-tools.sh update_geodata_in_docker "$XRAY_BUILD_DIR" + +ENV XRAY_BUILD_DIR=${XRAY_BUILD_DIR} + +#CMD ["/app/entrypoint.sh"] \ +ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/docker-cron-runner/cron-job-script.sh b/docker-cron-runner/cron-job-script.sh new file mode 100644 index 00000000..ec2eff8b --- /dev/null +++ b/docker-cron-runner/cron-job-script.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +echo "[$(date)] Starting geodata update..." + +FINISHED_FLAG="${SHARED_VOLUME_PATH}/cron-job-finished.txt" + +if [ -f "$FINISHED_FLAG" ]; then + rm -f "$FINISHED_FLAG" +fi + +/app/xray-tools.sh update_geodata_in_docker "${SHARED_VOLUME_PATH}" +touch "$FINISHED_FLAG" + +echo "[$(date)] Geodata update finished, restarting container..." + +HTTP_CODE=$( + curl -s -X POST \ + "${DOCKER_PROXY_URL}/containers/${TARGET_CONTAINER_NAME}/restart" \ + -o /dev/null -w "%{http_code}" +) + +echo "[$(date)] Restart request sent, HTTP status: ${HTTP_CODE}" \ No newline at end of file diff --git a/docker-cron-runner/entrypoint.sh b/docker-cron-runner/entrypoint.sh new file mode 100644 index 00000000..9d4cc001 --- /dev/null +++ b/docker-cron-runner/entrypoint.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env sh + +set -eu + +: "${CRON_SCHEDULE:=0 */6 * * *}" +: "${DOCKER_PROXY_URL:?DOCKER_PROXY_URL is required}" +: "${TARGET_CONTAINER_NAME:?TARGET_CONTAINER_NAME is required}" # required for cron-job-script.sh for container restart +: "${SHARED_VOLUME_PATH:?SHARED_VOLUME_PATH is required}" + +CRON_ENV_FILE="/env.sh" + +env | grep -v '^CRON_SCHEDULE=' | sed 's/^/export /' > "$CRON_ENV_FILE" +echo "${CRON_SCHEDULE} . ${CRON_ENV_FILE} && /app/cron-job-script.sh >> /var/log/cron.log 2>&1" > /etc/crontabs/root + +echo "Starting crond with schedule: ${CRON_SCHEDULE}" + +mkdir -p /var/log +touch /var/log/cron.log + +mkdir -p "$SHARED_VOLUME_PATH" +cp -r "$XRAY_BUILD_DIR"/* "$SHARED_VOLUME_PATH"/ + +touch "$SHARED_VOLUME_PATH/cron-job-finished.txt" # cron job execution imitation + +exec crond -f -l 2 \ No newline at end of file diff --git a/docker-cron-runner/xray-tools.sh b/docker-cron-runner/xray-tools.sh new file mode 100644 index 00000000..35ec12b6 --- /dev/null +++ b/docker-cron-runner/xray-tools.sh @@ -0,0 +1,173 @@ +#!/bin/sh + +safe_download_and_update() { + url="$1" + dest="$2" + + # Create a temporary file + tmp=$(mktemp "${dest}.XXXXXX") || return 1 + + # Download file into a temporary location + if wget -q -O "$tmp" "$url"; then + # Check that the downloaded file is not empty + if [ -s "$tmp" ]; then + # Atomically replace the destination file + mv "$tmp" "$dest" + echo "[OK] Downloaded: $dest" + else + echo "[ERR] Downloaded file is empty: $url" + rm -f "$tmp" + return 1 + fi + else + echo "[ERR] Failed to download: $url" + rm -f "$tmp" + return 1 + fi +} + +update_all_geofiles() { + update_main_geofiles + update_ir_geofiles + update_ru_geofiles +} + +update_main_geofiles() { + safe_download_and_update \ + "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" \ + "geoip.dat" + + safe_download_and_update \ + "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" \ + "geosite.dat" +} + +update_ir_geofiles() { + safe_download_and_update \ + "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" \ + "geoip_IR.dat" + + safe_download_and_update \ + "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" \ + "geosite_IR.dat" +} + +update_ru_geofiles() { + safe_download_and_update \ + "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" \ + "geoip_RU.dat" + + safe_download_and_update \ + "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" \ + "geosite_RU.dat" +} + +update_geodata_in_docker() { + XRAYDIR="$1" + OLD_DIR=$(pwd) + trap 'cd "$OLD_DIR"' EXIT + + echo "[$(date)] Running update_geodata" + + if [ ! -d "$XRAYDIR" ]; then + mkdir -p "$XRAYDIR" + fi + cd "$XRAYDIR" + + update_all_geofiles + echo "[$(date)] All geo files have been updated successfully!" +} + + +install_xray_core() { + TARGETARCH="$1" + XRAYDIR="$2" + XRAY_VERSION="$3" + + OLD_DIR=$(pwd) + trap 'cd "$OLD_DIR"' EXIT + + echo "[$(date)] Running install_xray_core" + + case $1 in + amd64) + ARCH="64" + FNAME="amd64" + ;; + i386) + ARCH="32" + FNAME="i386" + ;; + armv8 | arm64 | aarch64) + ARCH="arm64-v8a" + FNAME="arm64" + ;; + armv7 | arm | arm32) + ARCH="arm32-v7a" + FNAME="arm32" + ;; + armv6) + ARCH="arm32-v6" + FNAME="armv6" + ;; + *) + ARCH="64" + FNAME="amd64" + ;; + esac + + if [ ! -d "$XRAYDIR" ]; then + mkdir -p "$XRAYDIR" + fi + cd "$XRAYDIR" + + wget -q "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-${ARCH}.zip" + + # Validate the downloaded zip file + if [ ! -f "Xray-linux-${ARCH}.zip" ] || [ ! -s "Xray-linux-${ARCH}.zip" ]; then + echo "[ERR] Failed to download Xray-core zip or file is empty" + exit 1 + fi + + unzip -q "Xray-linux-${ARCH}.zip" -d ./xray-unzip + + # Validate the extracted xray binary + if [ -f "./xray-unzip/xray" ]; then + cp ./xray-unzip/xray ./"xray-linux-${FNAME}" + rm -r xray-unzip + rm "Xray-linux-${ARCH}.zip" + else + echo "[ERR] Failed to extract xray binary" + exit 1 + fi +} + +if [ "${0##*/}" = "xray-tools.sh" ]; then + cmd="$1" + shift || true + + case "$cmd" in + install_xray_core) + # args: TARGETARCH XRAYDIR XRAY_VERSION + install_xray_core "$@" + ;; + update_geodata_in_docker) + # args: XRAYDIR + update_geodata_in_docker "$@" + ;; + update_all_geofiles) + update_all_geofiles + ;; + ""|help|-h|--help) + echo "Usage:" + echo " $0 install_xray_core TARGETARCH XRAYDIR XRAY_VERSION" + echo " $0 update_geodata_in_docker XRAYDIR" + exit 0 + ;; + *) + echo "Unknown command: $cmd" >&2 + echo "Try: $0 help" >&2 + exit 1 + ;; + esac +fi \ No newline at end of file diff --git a/x-ui.sh b/x-ui.sh index bdb48817..426fb3aa 100644 --- a/x-ui.sh +++ b/x-ui.sh @@ -6,6 +6,8 @@ blue='\033[0;34m' yellow='\033[0;33m' plain='\033[0m' +source docker-cron-runner/xray-tools.sh + #Add some basic function here function LOGD() { echo -e "${yellow}[DEG] $* ${plain}" @@ -61,7 +63,7 @@ iplimit_log_path="${log_folder}/3xipl.log" iplimit_banned_log_path="${log_folder}/3xipl-banned.log" confirm() { - if [[ $# > 1 ]]; then + if [[ $# -gt 1 ]]; then echo && read -rp "$1 [Default $2]: " temp if [[ "${temp}" == "" ]]; then temp=$2 @@ -872,24 +874,6 @@ delete_ports() { fi } -update_all_geofiles() { - update_geofiles "main" - update_geofiles "IR" - update_geofiles "RU" -} - -update_geofiles() { - case "${1}" in - "main") dat_files=(geoip geosite); dat_source="Loyalsoldier/v2ray-rules-dat";; - "IR") dat_files=(geoip_IR geosite_IR); dat_source="chocolate4u/Iran-v2ray-rules" ;; - "RU") dat_files=(geoip_RU geosite_RU); dat_source="runetfreedom/russia-v2ray-rules-dat";; - esac - for dat in "${dat_files[@]}"; do - curl -fLRo ${xui_folder}/bin/${dat}.dat -z ${xui_folder}/bin/${dat}.dat \ - https://github.com/${dat_source}/releases/latest/download/${dat%%_}.dat - done -} - update_geo() { echo -e "${green}\t1.${plain} Loyalsoldier (geoip.dat, geosite.dat)" echo -e "${green}\t2.${plain} chocolate4u (geoip_IR.dat, geosite_IR.dat)" @@ -903,17 +887,17 @@ update_geo() { show_menu ;; 1) - update_geofiles "main" + update_main_geofiles echo -e "${green}Loyalsoldier datasets have been updated successfully!${plain}" restart ;; 2) - update_geofiles "IR" + update_ir_geofiles echo -e "${green}chocolate4u datasets have been updated successfully!${plain}" restart ;; 3) - update_geofiles "RU" + update_ru_geofiles echo -e "${green}runetfreedom datasets have been updated successfully!${plain}" restart ;; @@ -1074,28 +1058,28 @@ ssl_cert_issue_main() { ssl_cert_issue_for_ip() { LOGI "Starting automatic SSL certificate generation for server IP..." LOGI "Using Let's Encrypt shortlived profile (~6 days validity, auto-renews)" - + local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}') local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}') - + # Get server IP local server_ip=$(curl -s --max-time 3 https://api.ipify.org) if [ -z "$server_ip" ]; then server_ip=$(curl -s --max-time 3 https://4.ident.me) fi - + if [ -z "$server_ip" ]; then LOGE "Failed to get server IP address" return 1 fi - + LOGI "Server IP detected: ${server_ip}" - + # Ask for optional IPv6 local ipv6_addr="" read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr ipv6_addr="${ipv6_addr// /}" # Trim whitespace - + # check for acme.sh first if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then LOGI "acme.sh not found, installing..." @@ -1105,7 +1089,7 @@ ssl_cert_issue_for_ip() { return 1 fi fi - + # install socat case "${release}" in ubuntu | debian | armbian) @@ -1134,26 +1118,26 @@ ssl_cert_issue_for_ip() { LOGW "Unsupported OS for automatic socat installation" ;; esac - + # Create certificate directory certPath="/root/cert/ip" mkdir -p "$certPath" - + # Build domain arguments local domain_args="-d ${server_ip}" if [[ -n "$ipv6_addr" ]] && is_ipv6 "$ipv6_addr"; then domain_args="${domain_args} -d ${ipv6_addr}" LOGI "Including IPv6 address: ${ipv6_addr}" fi - + # Use port 80 for certificate issuance local WebPort=80 LOGI "Using port ${WebPort} to issue certificate for IP: ${server_ip}" LOGI "Make sure port ${WebPort} is open and not in use..." - + # Reload command - restarts panel after renewal local reloadCmd="systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null" - + # issue the certificate for IP with shortlived profile ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt ~/.acme.sh/acme.sh --issue \ @@ -1164,7 +1148,7 @@ ssl_cert_issue_for_ip() { --days 6 \ --httpport ${WebPort} \ --force - + if [ $? -ne 0 ]; then LOGE "Failed to issue certificate for IP: ${server_ip}" LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet" @@ -1176,7 +1160,7 @@ ssl_cert_issue_for_ip() { else LOGI "Certificate issued successfully for IP: ${server_ip}" fi - + # Install the certificate # Note: acme.sh may report "Reload error" and exit non-zero if reloadcmd fails, # but the cert files are still installed. We check for files instead of exit code. @@ -1184,7 +1168,7 @@ ssl_cert_issue_for_ip() { --key-file "${certPath}/privkey.pem" \ --fullchain-file "${certPath}/fullchain.pem" \ --reloadcmd "${reloadCmd}" 2>&1 || true - + # Verify certificate files exist (don't rely on exit code - reloadcmd failure causes non-zero) if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then LOGE "Certificate files not found after installation" @@ -1194,18 +1178,18 @@ ssl_cert_issue_for_ip() { rm -rf ${certPath} 2>/dev/null return 1 fi - + LOGI "Certificate files installed successfully" - + # enable auto-renew ~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1 chmod 600 $certPath/privkey.pem 2>/dev/null chmod 644 $certPath/fullchain.pem 2>/dev/null - + # Set certificate paths for the panel local webCertFile="${certPath}/fullchain.pem" local webKeyFile="${certPath}/privkey.pem" - + if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then ${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" LOGI "Certificate configured for panel" @@ -1275,17 +1259,17 @@ ssl_cert_issue() { while true; do read -rp "Please enter your domain name: " domain domain="${domain// /}" # Trim whitespace - + if [[ -z "$domain" ]]; then LOGE "Domain name cannot be empty. Please try again." continue fi - + if ! is_domain "$domain"; then LOGE "Invalid domain format: ${domain}. Please enter a valid domain name." continue fi - + break done LOGD "Your domain is: ${domain}, checking it..." @@ -1834,7 +1818,7 @@ remove_iplimit() { dnf autoremove -y ;; centos) - if [[ "${VERSION_ID}" =~ ^7 ]]; then + if [[ "${VERSION_ID}" =~ ^7 ]]; then yum remove fail2ban -y yum autoremove -y else @@ -2219,7 +2203,7 @@ show_menu() { esac } -if [[ $# > 0 ]]; then +if [[ $# -gt 0 ]]; then case $1 in "start") check_install 0 && start 0