Initial commit
This commit is contained in:
commit
85515c9528
5 changed files with 160 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
config.json
|
||||||
37
README.md
Normal file
37
README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# XIVLauncher OTP Wrapper
|
||||||
|
|
||||||
|
This is a simple wrapper script for Linux that launches `xivlauncher-core` and automatically injects your One-Time Password (OTP) when the launcher requests it.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Configure:**
|
||||||
|
Copy the example config and edit it with your details.
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
nano config.json
|
||||||
|
```
|
||||||
|
* `secret`: Enter your TOTP secret key (in Base32 format, usually provided when you set up your authenticator app).
|
||||||
|
* `launcher_cmd`: The command to launch XIVLauncher (default: `xivlauncher-core`).
|
||||||
|
|
||||||
|
2. **Make Executable:**
|
||||||
|
Ensure the script is executable:
|
||||||
|
```bash
|
||||||
|
chmod +x wrapper.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **XIVLauncher Settings:**
|
||||||
|
Ensure that "Enable XL Authenticator app/OTP macro support" is enabled in XIVLauncher settings.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Simply run the wrapper instead of the launcher:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./wrapper.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Launch XIVLauncher.
|
||||||
|
2. Wait for the launcher to start its local HTTP server (port 4646).
|
||||||
|
3. Generate the current OTP code.
|
||||||
|
4. Send it to the launcher automatically.
|
||||||
BIN
__pycache__/wrapper.cpython-313.pyc
Normal file
BIN
__pycache__/wrapper.cpython-313.pyc
Normal file
Binary file not shown.
4
config.example.json
Normal file
4
config.example.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"secret": "YOUR_BASE32_SECRET_HERE",
|
||||||
|
"launcher_cmd": "xivlauncher-core"
|
||||||
|
}
|
||||||
118
wrapper.py
Executable file
118
wrapper.py
Executable file
|
|
@ -0,0 +1,118 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import hmac
|
||||||
|
import base64
|
||||||
|
import struct
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
def get_totp_token(secret):
|
||||||
|
"""Generates a TOTP code from the secret."""
|
||||||
|
try:
|
||||||
|
# Pad the secret if needed for base32 decoding
|
||||||
|
secret_clean = secret.replace(" ", "").upper()
|
||||||
|
padding = '=' * ((8 - len(secret_clean) % 8) % 8)
|
||||||
|
key = base64.b32decode(secret_clean + padding)
|
||||||
|
|
||||||
|
# Time step is 30 seconds
|
||||||
|
msg = struct.pack(">Q", int(time.time() / 30))
|
||||||
|
h = hmac.new(key, msg, hashlib.sha1).digest()
|
||||||
|
o = h[19] & 15
|
||||||
|
h = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000
|
||||||
|
return f"{h:06d}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating TOTP: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send_otp(secret):
|
||||||
|
"""Attempts to send the OTP to the local XIVLauncher server."""
|
||||||
|
url_base = "http://127.0.0.1:4646/ffxivlauncher/"
|
||||||
|
print("Waiting for XIVLauncher to accept OTP...")
|
||||||
|
|
||||||
|
# Try for up to 5 minutes (300 seconds) - launcher might take time to load
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < 300:
|
||||||
|
otp = get_totp_token(secret)
|
||||||
|
if not otp:
|
||||||
|
print("Failed to generate OTP token.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Short timeout so we don't block
|
||||||
|
with urllib.request.urlopen(f"{url_base}{otp}", timeout=1) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
print(f"SUCCESS: OTP {otp} sent to XIVLauncher!")
|
||||||
|
# We can stop trying once successful
|
||||||
|
return
|
||||||
|
except (urllib.error.URLError, ConnectionRefusedError, ConnectionResetError):
|
||||||
|
# Server not up or not accepting connections yet
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
# Some other error (e.g. malformed URL if secret is bad?)
|
||||||
|
print(f"Debug: Connection error: {e}")
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print("Timed out waiting for XIVLauncher OTP prompt.")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Determine config path (look in script directory)
|
||||||
|
script_dir = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
config_path = os.path.join(script_dir, "config.json")
|
||||||
|
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
print(f"Config file not found at: {config_path}")
|
||||||
|
print("Please copy config.example.json to config.json and fill in your secret.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f"Error parsing {config_path}. Invalid JSON.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
secret = config.get("secret")
|
||||||
|
cmd = config.get("launcher_cmd", "xivlauncher-core")
|
||||||
|
|
||||||
|
if not secret or secret == "YOUR_BASE32_SECRET_HERE":
|
||||||
|
print("Error: Invalid or missing 'secret' in config.json.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Launching: {cmd}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Launch the process
|
||||||
|
# If the user provided arguments to this script, we could pass them through?
|
||||||
|
# But for now, just launch the command from config
|
||||||
|
proc = subprocess.Popen(cmd.split())
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Error: Command '{cmd}' not found in PATH.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Start the OTP injector in a background thread
|
||||||
|
injector_thread = threading.Thread(target=send_otp, args=(secret,))
|
||||||
|
injector_thread.daemon = True
|
||||||
|
injector_thread.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Wait for the launcher to exit
|
||||||
|
proc.wait()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopping wrapper...")
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
sys.exit(proc.returncode)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue