diff --git a/Dockerfile b/Dockerfile index 5a33825..e5c3c7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update \ && \ apt-get install -y --no-install-recommends --no-install-suggests \ python3 \ + python3-pip \ lib32stdc++6 \ lib32gcc-s1 \ libcurl4 \ @@ -15,6 +16,7 @@ RUN apt-get update \ ca-certificates \ curl \ libstdc++6 \ + git \ && \ apt-get remove --purge -y \ && \ @@ -22,13 +24,13 @@ RUN apt-get update \ && \ apt-get autoremove -y \ && \ - rm -rf /var/lib/apt/lists/* \ - && \ - mkdir -p /steamcmd \ - && \ - wget -qO- 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz' | tar zxf - -C /steamcmd + rm -rf /var/lib/apt/lists/* + +RUN pip3 install -U zstandard "git+https://github.com/brettmayson/valvepythonsteam#egg=steam[client]" + +ENV PYTHONUNBUFFERED=1 -ENV ARMA_BINARY=./arma3server +ENV ARMA_BINARY=./arma3server_x64 ENV ARMA_CONFIG=main.cfg ENV ARMA_PARAMS= ENV ARMA_PROFILE=main @@ -38,10 +40,8 @@ ENV ARMA_CDLC= ENV HEADLESS_CLIENTS=0 ENV HEADLESS_CLIENTS_PROFILE="\$profile-hc-\$i" ENV PORT=2302 -ENV STEAM_BRANCH=public -ENV STEAM_BRANCH_PASSWORD= -ENV STEAM_ADDITIONAL_DEPOT= ENV MODS_LOCAL=true +ENV CLEAR_KEYS=true ENV MODS_PRESET= ENV SKIP_INSTALL=false @@ -53,14 +53,7 @@ EXPOSE 2306/udp WORKDIR /arma3 -VOLUME /steamcmd -VOLUME /arma3/addons -VOLUME /arma3/enoch -VOLUME /arma3/expansion -VOLUME /arma3/jets -VOLUME /arma3/heli -VOLUME /arma3/orange -VOLUME /arma3/argo +VOLUME /arma3/server STOPSIGNAL SIGINT diff --git a/README.md b/README.md index c70144e..3409c85 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ An Arma 3 Dedicated Server. Updates to the latest version every time it is resta -p 2304:2304/udp \ -p 2305:2305/udp \ -p 2306:2306/udp \ - -v path/to/missions:/arma3/mpmissions \ - -v path/to/configs:/arma3/configs \ - -v path/to/mods:/arma3/mods \ - -v path/to/servermods:/arma3/servermods \ + -v path/to/missions:/arma3/server/mpmissions \ + -v path/to/configs:/arma3/server/configs \ + -v path/to/mods:/arma3/server/mods \ + -v path/to/servermods:/arma3/server/servermods \ -e ARMA_CONFIG=main.cfg \ -e STEAM_USER=myusername \ -e STEAM_PASSWORD=mypassword \ @@ -42,27 +42,26 @@ Use `docker-compose up -d` to start the server, detached. See [Docker-compose](https://docs.docker.com/compose/install/#install-compose) for an installation guide. -Profiles are saved in `/arma3/configs/profiles` +Profiles are saved in `/arma3/server/configs/profiles` ## Parameters | Parameter | Function | Default | | ------------- |-------------- | - | | `-p 2302-2306` | Ports required by Arma 3 | -| `-v /arma3/mpmission` | Folder with MP Missions | -| `-v /arma3/configs` | Folder containing config files | -| `-v /arma3/mods` | Mods that will be loaded by clients | -| `-v /arma3/servermods` | Mods that will only be loaded by the server | +| `-v /arma3/server/mpmission` | Folder with MP Missions | +| `-v /arma3/server/configs` | Folder containing config files | +| `-v /arma3/server/mods` | Mods that will be loaded by clients | +| `-v /arma3/server/servermods` | Mods that will only be loaded by the server | +| `-v /arma3/server` | Folder containing the server files | | `-e PORT` | Port used by the server, (uses PORT to PORT+3) | 2302 | -| `-e ARMA_BINARY` | Arma 3 server binary to use, `./arma3server_x64` for x64 | `./arma3server` | -| `-e ARMA_CONFIG` | Config file to load from `/arma3/configs` | `main.cfg` | +| `-e ARMA_BINARY` | Arma 3 server binary to use | `./arma3server` | +| `-e ARMA_CONFIG` | Config file to load from `/arma3/server/configs` | `main.cfg` | | `-e ARMA_PARAMS` | Additional Arma CLI parameters | -| `-e ARMA_PROFILE` | Profile name, stored in `/arma3/configs/profiles` | `main` | +| `-e ARMA_PROFILE` | Profile name, stored in `/arma3/server/configs/profiles` | `main` | | `-e ARMA_WORLD` | World to load on startup | `empty` | | `-e ARMA_LIMITFPS` | Maximum FPS | `1000` | -| `-e ARMA_CDLC` | cDLCs to load | -| `-e STEAM_BRANCH` | Steam branch used by steamcmd | `public` | -| `-e STEAM_BRANCH_PASSWORD` | Steam branch password used by steamcmd | +| `-e ARMA_CDLC` | cDLCs to load, separated by semicolons | - | | `-e STEAM_USER` | Steam username used to login to steamcmd | | `-e STEAM_PASSWORD` | Steam password | | `-e HEADLESS_CLIENTS` | Launch n number of headless clients | `0` | @@ -70,7 +69,7 @@ Profiles are saved in `/arma3/configs/profiles` | `-e MODS_LOCAL` | Should the mods folder be loaded | `true` | | `-e MODS_PRESET` | An Arma 3 Launcher preset to load | | `-e SKIP_INSTALL` | Skip Arma 3 installation | `false` | -| `-e CLEAR_KEYS` | Clear the keys directory every launch (keys will still be copied from mods) | `false` | +| `-e CLEAR_KEYS` | Clear the keys directory every launch (keys will still be copied from mods) | `true` | The Steam account does not need to own Arma 3, but must have Steam Guard disabled. @@ -82,10 +81,10 @@ To use a Creator DLC the `STEAM_BRANCH` must be set to `creatordlc` | Name | Flag | | ---- | ---- | -| [CSLA Iron Curtain](https://store.steampowered.com/app/1294440/Arma_3_Creator_DLC_CSLA_Iron_Curtain/) | CSLA | -| [Global Mobilization - Cold War Germany](https://store.steampowered.com/app/1042220/Arma_3_Creator_DLC_Global_Mobilization__Cold_War_Germany/) | GM | +| [CSLA Iron Curtain](https://store.steampowered.com/app/1294440/Arma_3_Creator_DLC_CSLA_Iron_Curtain/) | csla | +| [Global Mobilization - Cold War Germany](https://store.steampowered.com/app/1042220/Arma_3_Creator_DLC_Global_Mobilization__Cold_War_Germany/) | gm | | [S.O.G. Prairie Fire](https://store.steampowered.com/app/1227700/Arma_3_Creator_DLC_SOG_Prairie_Fire) | vn | -| [Western Sahara](https://store.steampowered.com/app/1681170/Arma_3_Creator_DLC_Western_Sahara/) | WS | +| [Western Sahara](https://store.steampowered.com/app/1681170/Arma_3_Creator_DLC_Western_Sahara/) | ws | | [Spearhead 1944](https://store.steampowered.com/app/1175380/Arma_3_Creator_DLC_Spearhead_1944/) | spe | | [Reaction Forces](https://store.steampowered.com/app/2647760/Arma_3_Creator_DLC_Reaction_Forces/) | rf | | [Expeditionary Forces](https://store.steampowered.com/app/2647830/Arma_3_Creator_DLC_Expeditionary_Forces/) | ef | @@ -110,7 +109,7 @@ Bohemia-updated list of codes here: CACHE_EXPIRY_SECONDS: + print("Manifest cache expired, will refetch...") + return None + + return cache_data.get('manifests', []) + except (json.JSONDecodeError, FileNotFoundError): + print("Cache file corrupted or missing, will refetch...") + return None + +def save_manifests_to_cache(manifests): + """Save manifest data to cache with timestamp""" + os.makedirs(CACHE_DIR, exist_ok=True) + + manifest_data = [] + for manifest in manifests: + manifest_data.append({ + 'name': manifest.name, + 'gid': manifest.gid, + 'depot_id': manifest.depot_id + }) + + cache_data = { + 'timestamp': time.time(), + 'manifests': manifest_data + } + + with open(MANIFEST_CACHE_FILE, 'w') as f: + json.dump(cache_data, f, indent=2) + + print(f"Manifest data cached to {MANIFEST_CACHE_FILE}") + +def download_depot(client, depot_id): + cdn_client = CDNClient(client) + + cached_manifests = load_cached_manifests() + + if cached_manifests: + manifests = cached_manifests + print("Got manifests from cache for ARMA3 server app ID:", ARMA3_SERVER_APP_ID) + else: + print("Fetching fresh manifests from Steam...") + manifests_obj = cdn_client.get_manifests(ARMA3_SERVER_APP_ID, branch="creatordlc") + + save_manifests_to_cache(manifests_obj) + + manifests = [] + for manifest in manifests_obj: + manifests.append({ + 'name': manifest.name, + 'gid': manifest.gid, + 'depot_id': manifest.depot_id + }) + + target_manifest = next((m for m in manifests if m['depot_id'] == depot_id), None) + if not target_manifest: + print(f"No manifest found for depot ID {depot_id}") + return + + print(f"Downloading Manifest ID: {target_manifest['gid']}, Depot ID: {target_manifest['depot_id']}") + + files_generator = cdn_client.iter_files(ARMA3_SERVER_APP_ID, branch="creatordlc", filter_func=lambda d_id, depot_info: d_id == target_manifest['depot_id']) + files = list(files_generator) + files = [f for f in files if f.is_file] + print(f"Found {len(files)} files to download") + download_files(client, cdn_client, files, destination="server/") + +def download_workshop(client, workshop_id): + cdn_client = CDNClient(client) + workshop_manifest = cdn_client.get_manifest_for_workshop_item(workshop_id) + files_generator = workshop_manifest.iter_files() + files = list(files_generator) + files = [f for f in files if f.is_file] + print(f"Found {len(files)} files to download") + download_files(client, cdn_client, files, destination=f"server/workshop/{workshop_id}/") + +def download_files(client, cdn_client, files, destination): + print(f"Verifying {len(files)} files...") + files_to_download = [] + + for i, file in enumerate(files): + file.local = os.path.join(destination, file.filename).lower() + + if os.path.exists(file.local): + with open(file.local, 'rb') as f: + existing_hash = hashlib.sha1(f.read()).hexdigest() + expected_hash = file.sha_content.hex() if isinstance(file.sha_content, bytes) else file.sha_content + if existing_hash == expected_hash: + if file.is_executable: + os.chmod(file.local, 0o755) + continue + files_to_download.append(file) + + print(f"Need to download {len(files_to_download)} files...") + + for i, file in enumerate(files_to_download): + print(f"Downloading {i+1}/{len(files_to_download)}: {file.filename} ({file.size} bytes)") + _download_single_file(file) + + print("All files downloaded successfully.") + +def _download_single_file(file): + if file.local and os.path.dirname(file.local) != "": + os.makedirs(os.path.dirname(file.local), exist_ok=True) + + chunk_size = 1024 * 1024 # 1MB chunks + downloaded = 0 + failed = False + + with open(file.local, 'wb') as f: + while downloaded < file.size: + remaining = file.size - downloaded + read_size = min(chunk_size, remaining) + + chunk = file.read(read_size) + if not chunk: + break + + f.write(chunk) + downloaded += len(chunk) + + if downloaded % (10 * 1024 * 1024) == 0: + percent = (downloaded / file.size) * 100 + print(f" Progress: {percent:.1f}% ({downloaded}/{file.size} bytes)") + + if failed: + print(f"Failed to download {file.filename}") + else: + if file.is_executable: + os.chmod(file.local, 0o755) + print(f"✓ Downloaded {file.filename}") + +if __name__ == "__main__": + import os + import sys + + if len(sys.argv) != 3: + print("Usage: python api.py ") + sys.exit(1) + + username = sys.argv[1] + password = sys.argv[2] + + client = login(username, password) + if client: + download_depot(client, 233785) # Western Sahara + download_workshop(client, 463939057) # Example workshop ID for ACE3 mod diff --git a/docker-compose.yml b/docker-compose.yml index 3224a0f..6b75b94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,18 +7,10 @@ services: container_name: arma3 network_mode: host volumes: - - './missions:/arma3/mpmissions' - - './configs:/arma3/configs' - - './mods:/arma3/mods' - - './servermods:/arma3/servermods' - - 'addons:/arma3/addons' - - 'argo:/arma3/argo' - - 'enoch:/arma3/enoch' - - 'expansion:/arma3/expansion' - - 'heli:/arma3/heli' - - 'jets:/arma3/jets' - - 'orange:/arma3/orange' - - 'steamcmd:/steamcmd' + - './configs:/arma3/server/configs' + - './mods:/arma3/server/mods' + - './servermods:/arma3/server/servermods' + - './server:/arma3/server/' env_file: .env restart: unless-stopped volumes: diff --git a/keys.py b/keys.py index e52cead..081b353 100644 --- a/keys.py +++ b/keys.py @@ -8,7 +8,7 @@ def copy(moddir): if len(keys) > 0: for key in keys: if not os.path.isdir(key): - shutil.copy2(key, "/arma3/keys") + shutil.copy2(key, "/arma3/server/keys") else: print("Missing keys:", moddir) diff --git a/launch.py b/launch.py index 57f2b7b..c3db9ae 100644 --- a/launch.py +++ b/launch.py @@ -4,20 +4,20 @@ import subprocess from string import Template +import api import local import workshop +print("Starting Arma 3 Server...") def mod_param(name, mods): return ' -{}="{}" '.format(name, ";".join(mods)) - def env_defined(key): return key in os.environ and len(os.environ[key]) > 0 - CONFIG_FILE = os.environ["ARMA_CONFIG"] -KEYS = "/arma3/keys" +KEYS = "/arma3/server/keys" if env_defined("CLEAR_KEYS") and os.environ["CLEAR_KEYS"] == "true" and os.path.isdir(KEYS): shutil.rmtree(KEYS) @@ -26,46 +26,31 @@ def env_defined(key): os.remove(KEYS) os.makedirs(KEYS) +client = None if os.environ["SKIP_INSTALL"] in ["", "false"]: - # Install Arma - - steamcmd = ["/steamcmd/steamcmd.sh"] - steamcmd.extend(["+force_install_dir", "/arma3"]) - steamcmd.extend(["+login", os.environ["STEAM_USER"], os.environ["STEAM_PASSWORD"]]) - steamcmd.extend(["+app_update", "233780"]) - if env_defined("STEAM_BRANCH"): - steamcmd.extend(["-beta", os.environ["STEAM_BRANCH"]]) - if env_defined("STEAM_BRANCH_PASSWORD"): - steamcmd.extend(["-betapassword", os.environ["STEAM_BRANCH_PASSWORD"]]) - steamcmd.extend(["validate"]) - if env_defined("STEAM_ADDITIONAL_DEPOT"): - for depot in os.environ["STEAM_ADDITIONAL_DEPOT"].split("|"): - depot_parts = depot.split(",") - steamcmd.extend( - ["+login", os.environ["STEAM_USER"], os.environ["STEAM_PASSWORD"]] - ) - steamcmd.extend( - ["+download_depot", "233780", depot_parts[0], depot_parts[1]] - ) - steamcmd.extend(["+quit"]) - subprocess.call(steamcmd) - -if env_defined("STEAM_ADDITIONAL_DEPOT"): - for depot in os.environ["STEAM_ADDITIONAL_DEPOT"].split("|"): - depot_parts = depot.split(",") - depot_dir = ( - f"/steamcmd/linux32/steamapps/content/app_233780/depot_{depot_parts[0]}/" - ) - for file in os.listdir(depot_dir): - shutil.copytree(depot_dir + file, "/arma3/", dirs_exist_ok=True) - print(f"Moved {file} to /arma3") + client = api.login(os.environ["STEAM_USER"], os.environ["STEAM_PASSWORD"]) + if not client: + print("Failed to login to Steam, exiting...") + exit(1) + api.download_depot(client, 233781) # Default Content + api.download_depot(client, 233783) # Linux Server + if os.environ["ARMA_BINARY"] == "arma3serverprofiling_x64": + api.download_depot(client, 233785) # Arma 3 Profiling + + for cdlc in os.environ["ARMA_CDLC"].split(";"): + if cdlc: + cdlc = cdlc.lower() + print("Downloading CDLC:", cdlc) + api.download_depot(client, api.CDLC_IDS[cdlc]) # Mods mods = [] if os.environ["MODS_PRESET"] != "": - mods.extend(workshop.preset(os.environ["MODS_PRESET"])) + if not client: + client = api.login(os.environ["STEAM_USER"], os.environ["STEAM_PASSWORD"]) + mods.extend(workshop.preset(os.environ["MODS_PRESET"], client)) if os.environ["MODS_LOCAL"] == "true" and os.path.exists("mods"): mods.extend(local.mods("mods")) @@ -86,7 +71,7 @@ def env_defined(key): print("Headless Clients:", clients) if clients != 0: - with open("/arma3/configs/{}".format(CONFIG_FILE)) as config: + with open("/arma3/server/configs/{}".format(CONFIG_FILE)) as config: data = config.read() regex = r"(.+?)(?:\s+)?=(?:\s+)?(.+?)(?:$|\/|;)" @@ -123,9 +108,9 @@ def env_defined(key): subprocess.Popen(hc_launch, shell=True) else: - launch += ' -config="/arma3/configs/{}"'.format(CONFIG_FILE) + launch += ' -config="/arma3/server/configs/{}"'.format(CONFIG_FILE) -launch += ' -port={} -name="{}" -profiles="/arma3/configs/profiles"'.format( +launch += ' -port={} -name="{}" -profiles="/arma3/server/configs/profiles"'.format( os.environ["PORT"], os.environ["ARMA_PROFILE"] ) @@ -133,4 +118,5 @@ def env_defined(key): launch += mod_param("serverMod", local.mods("servermods")) print("LAUNCHING ARMA SERVER WITH", launch, flush=True) +os.chdir("/arma3/server") os.system(launch) diff --git a/workshop.py b/workshop.py index 9894335..be63092 100644 --- a/workshop.py +++ b/workshop.py @@ -2,24 +2,14 @@ import re import subprocess import urllib.request +import shutil import keys +import api -WORKSHOP = "steamapps/workshop/content/107410/" USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.47 Safari/537.36" # noqa: E501 - -def download(mods): - steamcmd = ["/steamcmd/steamcmd.sh"] - steamcmd.extend(["+force_install_dir", "/arma3"]) - steamcmd.extend(["+login", os.environ["STEAM_USER"], os.environ["STEAM_PASSWORD"]]) - for id in mods: - steamcmd.extend(["+workshop_download_item", "107410", id]) - steamcmd.extend(["+quit"]) - subprocess.call(steamcmd) - - -def preset(mod_file): +def preset(mod_file, client): if mod_file.startswith("http"): req = urllib.request.Request( mod_file, @@ -37,9 +27,8 @@ def preset(mod_file): matches = re.finditer(regex, html, re.MULTILINE) for _, match in enumerate(matches, start=1): mods.append(match.group(1)) - moddir = WORKSHOP + match.group(1) - moddirs.append(moddir) - download(mods) + api.download_workshop(client, int(match.group(1))) + moddirs.append("workshop/" + match.group(1)) for moddir in moddirs: - keys.copy(moddir) + keys.copy("server/"+moddir) return moddirs