[Linux] Help needed - Automated Mod Updater

Find multiplayer games.
Tools/scripts to run a dedicated server.
krisprolse
Manual Inserter
Manual Inserter
Posts: 1
Joined: Sun May 18, 2025 11:56 pm
Contact:

[Linux] Help needed - Automated Mod Updater

Post by krisprolse »

Hi everyone, I need your help.

I am writing a Python script to automate the updates of mods.
The script retrieves the credentials to get the token for logging in.
However, I keep getting a 403 error code when it tries to download a mod.

Code: Select all

import os
import re
import requests
import json
import sys
import platform
import traceback
from datetime import datetime
from cryptography.fernet import Fernet
import getpass

# Check if the operating system is Linux
if platform.system() != "Linux":
    print("Error: This script is only compatible with Linux.")
    sys.exit(1)

# Paths and static variables
FACTORIO_BINARY_PATH = os.path.join("bin", "x64", "factorio")
LOG_FILE = os.path.expanduser("~/mod_updater.log")
CREDENTIALS_FILE = os.path.expanduser("~/.factorio_credentials")
MODS_DIR = os.path.join(os.getcwd(), "mods")
MOD_LIST_FILE = os.path.join(MODS_DIR, "mod-list.json")
MODS_TO_IGNORE = ["base", "elevated_rails", "quality", "space_age"]

# Function to write to the log file
def log_error(message):
    try:
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        log_message = f"{timestamp} - {message}\n"
        with open(LOG_FILE, 'a') as f:
            f.write(log_message)
    except Exception as e:
        print(f"Error while writing to the log file: {str(e)}")

# Function to check read and write permissions
def check_permissions(path, mode):
    return os.access(path, mode)

# Function to check if sudo is installed
def is_sudo_installed():
    return os.system("which sudo > /dev/null 2>&1") == 0

# Function to restart the script with sudo
def restart_with_sudo():
    os.execvp("sudo", ["sudo", "python3"] + sys.argv)

# Generate an encryption key
def generate_key():
    return Fernet.generate_key()

# Encrypt credentials
def encrypt_credentials(key, credentials):
    fernet = Fernet(key)
    return fernet.encrypt(credentials.encode())

# Decrypt credentials
def decrypt_credentials(key, encrypted_credentials):
    fernet = Fernet(key)
    return fernet.decrypt(encrypted_credentials).decode()

# Request credentials
def get_credentials():
    username = input("Username: ")
    password = getpass.getpass("Password: ")
    return f"{username}:{password}"

# Save encrypted credentials
def save_credentials(key, encrypted_credentials):
    with open(CREDENTIALS_FILE, 'wb') as f:
        f.write(key + b'\n' + encrypted_credentials)

# Load encrypted credentials
def load_credentials():
    with open(CREDENTIALS_FILE, 'rb') as f:
        key = f.readline().strip()
        encrypted_credentials = f.readline().strip()
    return key, encrypted_credentials

# Get the authentication token
def get_auth_token(username, password):
    auth_url = "https://auth.factorio.com/api-login"
    response = requests.post(auth_url, data={"username": username, "password": password})

    if response.status_code == 200:
        try:
            # Check if the response is valid JSON
            response_json = response.json()
            if isinstance(response_json, list) and len(response_json) > 0:
                return response_json[0]  # Retrieve the first element of the list as the token
            else:
                error_message = f"Error: Unexpected API response: {response_json}"
                print(error_message)
                log_error(error_message)
                return None
        except ValueError:
            error_message = f"Error: Non-JSON API response: {response.text}"
            print(error_message)
            log_error(error_message)
            return None
    else:
        error_message = f"Error during authentication: {response.status_code} {response.text}"
        print(error_message)
        log_error(error_message)
        return None

# Check if credentials are already saved
if os.path.exists(CREDENTIALS_FILE):
    key, encrypted_credentials = load_credentials()
    credentials = decrypt_credentials(key, encrypted_credentials)
else:
    credentials = get_credentials()
    key = generate_key()
    encrypted_credentials = encrypt_credentials(key, credentials)
    save_credentials(key, encrypted_credentials)

# Use credentials for authentication
username, password = credentials.split(':')
auth_token = get_auth_token(username, password)

if not auth_token:
    print("Error: Unable to obtain the authentication token.")
    sys.exit(1)

# Function to download a file
def download_file(url, destination, token):
    try:
        if not check_permissions(os.path.dirname(destination), os.W_OK):
            error_message = f"Error: Write permission denied for {destination}"
            print(error_message)
            log_error(error_message)
            return False
        headers = {"Authorization": f"Bearer {token}"}
        response = requests.get(url, headers=headers)
        response.raise_for_status()  # Check for HTTP errors
        with open(destination, 'wb') as f:
            f.write(response.content)
        return True
    except Exception as e:
        error_message = f"Error while downloading {url}: {str(e)}"
        print(error_message)
        log_error(error_message)
        return False

# Function to get the latest version of a mod
def get_latest_mod_version(mod_name, token):
    try:
        api_url = f"https://mods.factorio.com/api/mods/{mod_name}"
        headers = {"Authorization": f"Bearer {token}"}
        response = requests.get(api_url, headers=headers)
        response.raise_for_status()  # Check for HTTP errors
        if response.status_code == 200:
            mod_info = response.json()
            if "releases" in mod_info and mod_info["releases"]:
                return mod_info["releases"][-1]["version"]  # Get the latest version
            else:
                error_message = f"Error: No version found for {mod_name}"
                print(error_message)
                log_error(error_message)
                return None
        else:
            error_message = f"Error: Unable to retrieve the latest version for {mod_name}"
            print(error_message)
            log_error(error_message)
            return None
    except Exception as e:
        error_message = f"Error while retrieving the latest version for {mod_name}: {str(e)}"
        print(error_message)
        log_error(error_message)
        return None

# Function to extract the mod name and version from the zip file name
def extract_mod_name_and_version(filename):
    # Pattern to extract the mod name and version
    mod_pattern = re.compile(r"^([A-Za-z0-9_-]+)_(\d+\.\d+\.\d+)\.zip$")
    match = mod_pattern.match(filename)
    if match:
        return match.group(1), match.group(2)
    return None, None

# Function to check if a mod is present in the mods directory
def is_mod_present(mod_name):
    mod_zip_pattern = re.compile(r"^([A-Za-z0-9_-]+)_(\d+\.\d+\.\d+)\.zip$")
    for file in os.listdir(MODS_DIR):
        match = mod_zip_pattern.match(file)
        if match and match.group(1) == mod_name:
            return True
    return False

# Function to extract the version from the zip file name
def get_current_mod_version(mod_name):
    if not check_permissions(MODS_DIR, os.R_OK):
        error_message = f"Error: Read permission denied for {MODS_DIR}"
        print(error_message)
        log_error(error_message)
        return None
    mod_zip_pattern = re.compile(r"^([A-Za-z0-9_-]+)_(\d+\.\d+\.\d+)\.zip$")
    for file in os.listdir(MODS_DIR):
        match = mod_zip_pattern.match(file)
        if match and match.group(1) == mod_name:
            return match.group(2)
    return None

# Function to delete the old version of a mod
def delete_old_mod_version(mod_name, current_version):
    old_mod_zip_path = os.path.join(MODS_DIR, f"{mod_name}_{current_version}.zip")
    if os.path.exists(old_mod_zip_path):
        if not check_permissions(MODS_DIR, os.W_OK):
            error_message = f"Error: Write permission denied for {MODS_DIR}"
            print(error_message)
            log_error(error_message)
            return False
        os.remove(old_mod_zip_path)
        print(f"Old version of {mod_name} deleted.")
        return True
    return False

# Function to download a mod
def download_mod(mod_name, mod_version, token):
    try:
        # Get the download URL via the Factorio API
        api_url = f"https://mods.factorio.com/api/mods/{mod_name}"
        headers = {"Authorization": f"Bearer {token}"}
        response = requests.get(api_url, headers=headers)
        response.raise_for_status()  # Check for HTTP errors
        if response.status_code == 200:
            mod_info = response.json()
            # Find the specific version in the releases
            for release in mod_info["releases"]:
                if release["version"] == mod_version:
                    download_url = release["download_url"]
                    break
            else:
                error_message = f"Error: Version {mod_version} not found for {mod_name}"
                print(error_message)
                log_error(error_message)
                return None

            # Ensure the download URL is correctly formatted
            if not download_url.startswith("https://mods.factorio.com/"):
                download_url = f"https://mods.factorio.com{download_url}"

            mod_zip_path = os.path.join(MODS_DIR, f"{mod_name}_{mod_version}.zip")

            print(f"Downloading {mod_name} version {mod_version}...")
            try:
                if download_file(download_url, mod_zip_path, token):
                    return mod_zip_path
            except requests.exceptions.HTTPError as e:
                if e.response.status_code == 403:
                    error_message = f"Error 403: Access forbidden for {download_url}. Check permissions or authentication."
                    print(error_message)
                    log_error(error_message)
                else:
                    error_message = f"Error while downloading {mod_name}: {str(e)}"
                    print(error_message)
                    log_error(error_message)
            return None
        else:
            error_message = f"Error: Unable to retrieve the download URL for {mod_name}"
            print(error_message)
            log_error(error_message)
            return None
    except Exception as e:
        error_message = f"Error while downloading {mod_name}: {str(e)}"
        print(error_message)
        log_error(error_message)
        return None

# Function to check and update mods
def check_and_update_mods(token):
    if not os.path.exists(MODS_DIR) or not os.path.exists(MOD_LIST_FILE):
        print("No mods are installed.")
        return

    if not check_permissions(MOD_LIST_FILE, os.R_OK):
        error_message = f"Error: Read permission denied for {MOD_LIST_FILE}"
        print(error_message)
        log_error(error_message)
        return

    with open(MOD_LIST_FILE, 'r') as f:
        mods_data = json.load(f)

    if "mods" not in mods_data or not mods_data["mods"]:
        print("No mods are installed.")
        return

    for mod in mods_data["mods"]:
        if isinstance(mod, dict) and "name" in mod:
            mod_name = mod["name"]

            # Ignore specific mods
            if mod_name in MODS_TO_IGNORE:
                print(f"Ignoring mod {mod_name}.")
                continue

            latest_version = get_latest_mod_version(mod_name, token)

            if not is_mod_present(mod_name):
                print(f"The mod {mod_name} is not present. Downloading the latest version...")
                download_mod(mod_name, latest_version, token)
            else:
                current_version = get_current_mod_version(mod_name)
                if latest_version and latest_version != current_version:
                    new_mod_zip_path = download_mod(mod_name, latest_version, token)
                    if new_mod_zip_path and current_version:
                        delete_old_mod_version(mod_name, current_version)
                else:
                    print(f"The mod {mod_name} is already up to date or unable to find the latest version.")
        else:
            error_message = f"Invalid element in mod-list.json: {mod}"
            print(error_message)
            log_error(error_message)

    print("Mod updates completed.")

# Check if the script is run from the root of the Factorio directory
if not os.path.exists(FACTORIO_BINARY_PATH):
    error_message = "Error: The script must be executed from the root of the Factorio directory."
    print(error_message)
    log_error(error_message)
    sys.exit(1)

# Check permissions
if not check_permissions(MODS_DIR, os.R_OK | os.W_OK) or not check_permissions(MOD_LIST_FILE, os.R_OK):
    if is_sudo_installed():
        print("Restarting the script with sudo...")
        restart_with_sudo()
    else:
        error_message = "Error: The sudo package is not installed. Please install sudo and restart the script in superuser mode."
        print(error_message)
        log_error(error_message)
        sys.exit(1)
else:
    check_and_update_mods(auth_token)
Thanks for your help.
User avatar
vinzenz
Factorio Staff
Factorio Staff
Posts: 364
Joined: Mon Aug 02, 2021 6:45 pm
Contact:

Re: [Linux] Help needed - Automated Mod Updater

Post by vinzenz »

The relevant APIs and our http usage guidelines are documented here: https://wiki.factorio.com/Factorio_HTTP ... guidelines
bringing the oops to devops
Post Reply

Return to “Multiplayer / Dedicated Server”