diff --git a/basicswap/bin/prepare.py b/basicswap/bin/prepare.py index fc2ceff..6f72fbe 100755 --- a/basicswap/bin/prepare.py +++ b/basicswap/bin/prepare.py @@ -1734,6 +1734,8 @@ def printHelp(): print( "--dashv20compatible Generate the same DASH wallet seed as for DASH v20 - Use only when importing an existing seed." ) + print("--client-auth-password= Set or update the password to protect the web UI.") + print("--disable-client-auth Remove password protection from the web UI.") active_coins = [] for coin_name in known_coins.keys(): @@ -2166,6 +2168,8 @@ def main(): disable_tor = False initwalletsonly = False tor_control_password = None + client_auth_pwd_value = None + disable_client_auth_flag = False extra_opts = {} if os.getenv("SSL_CERT_DIR", "") == "" and GUIX_SSL_CERT_DIR is not None: @@ -2298,7 +2302,15 @@ def main(): if name == "trustremotenode": extra_opts["trust_remote_node"] = toBool(s[1]) continue + if name == "client-auth-password": + client_auth_pwd_value = s[1].strip('"') + continue + if name == "disable-client-auth": + disable_client_auth_flag = True + continue + if len(s) != 2: + exitWithError("Unknown argument {}".format(v)) exitWithError("Unknown argument {}".format(v)) if print_versions: @@ -2328,6 +2340,34 @@ def main(): os.makedirs(data_dir) config_path = os.path.join(data_dir, cfg.CONFIG_FILENAME) + config_exists = os.path.exists(config_path) + if config_exists and ( + client_auth_pwd_value is not None or disable_client_auth_flag + ): + try: + settings = load_config(config_path) + modified = False + if client_auth_pwd_value is not None: + settings["client_auth_hash"] = rfc2440_hash_password( + client_auth_pwd_value + ) + logger.info("Client authentication password updated.") + modified = True + elif disable_client_auth_flag: + if "client_auth_hash" in settings: + del settings["client_auth_hash"] + logger.info("Client authentication disabled.") + modified = True + else: + logger.info("Client authentication is already disabled.") + + if modified: + with open(config_path, "w") as fp: + json.dump(settings, fp, indent=4) + return 0 + except Exception as e: + exitWithError(f"Failed to update client auth settings: {e}") + if use_tor_proxy and extra_opts.get("no_tor_proxy", False): exitWithError("Can't use --usetorproxy and --notorproxy together") @@ -2969,6 +3009,10 @@ def main(): tor_control_password = generate_salt(24) addTorSettings(settings, tor_control_password) + if client_auth_pwd_value is not None: + settings["client_auth_hash"] = rfc2440_hash_password(client_auth_pwd_value) + logger.info("Client authentication password set.") + if not no_cores: for c in with_coins: prepareCore(c, known_coins[c], settings, data_dir, extra_opts) diff --git a/basicswap/http_server.py b/basicswap/http_server.py index e1e2774..7a6428b 100644 --- a/basicswap/http_server.py +++ b/basicswap/http_server.py @@ -1,21 +1,25 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019-2024 tecnovert -# Copyright (c) 2024 The Basicswap developers +# Copyright (c) 2024-2025 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. import os import json import shlex +import secrets import traceback import threading import http.client +import base64 from http.server import BaseHTTPRequestHandler, HTTPServer from jinja2 import Environment, PackageLoader from socket import error as SocketError from urllib import parse +from datetime import datetime, timedelta, timezone +from http.cookies import SimpleCookie from . import __version__ from .util import ( @@ -32,6 +36,8 @@ from .basicswap_util import ( strTxState, strBidState, ) +from .util.rfc2440 import verify_rfc2440_password + from .js_server import ( js_error, js_url_to_function, @@ -58,6 +64,9 @@ from .ui.page_identity import page_identity from .ui.page_smsgaddresses import page_smsgaddresses from .ui.page_debug import page_debug +SESSION_COOKIE_NAME = "basicswap_session_id" +SESSION_DURATION_MINUTES = 60 + env = Environment(loader=PackageLoader("basicswap", "templates")) env.filters["formatts"] = format_timestamp @@ -120,6 +129,57 @@ def parse_cmd(cmd: str, type_map: str): class HttpHandler(BaseHTTPRequestHandler): + def _get_session_cookie(self): + if "Cookie" in self.headers: + cookie = SimpleCookie(self.headers["Cookie"]) + if SESSION_COOKIE_NAME in cookie: + return cookie[SESSION_COOKIE_NAME].value + return None + + def _set_session_cookie(self, session_id): + cookie = SimpleCookie() + cookie[SESSION_COOKIE_NAME] = session_id + cookie[SESSION_COOKIE_NAME]["path"] = "/" + cookie[SESSION_COOKIE_NAME]["httponly"] = True + cookie[SESSION_COOKIE_NAME]["samesite"] = "Lax" + expires = datetime.now(timezone.utc) + timedelta( + minutes=SESSION_DURATION_MINUTES + ) + cookie[SESSION_COOKIE_NAME]["expires"] = expires.strftime( + "%a, %d %b %Y %H:%M:%S GMT" + ) + return ("Set-Cookie", cookie.output(header="").strip()) + + def _clear_session_cookie(self): + cookie = SimpleCookie() + cookie[SESSION_COOKIE_NAME] = "" + cookie[SESSION_COOKIE_NAME]["path"] = "/" + cookie[SESSION_COOKIE_NAME]["httponly"] = True + cookie[SESSION_COOKIE_NAME]["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT" + return ("Set-Cookie", cookie.output(header="").strip()) + + def is_authenticated(self): + swap_client = self.server.swap_client + client_auth_hash = swap_client.settings.get("client_auth_hash") + + if not client_auth_hash: + return True + + session_id = self._get_session_cookie() + if not session_id: + return False + + session_data = self.server.active_sessions.get(session_id) + if session_data and session_data["expires"] > datetime.now(timezone.utc): + session_data["expires"] = datetime.now(timezone.utc) + timedelta( + minutes=SESSION_DURATION_MINUTES + ) + return True + + if session_id in self.server.active_sessions: + del self.server.active_sessions[session_id] + return False + def log_error(self, format, *args): super().log_message(format, *args) @@ -142,7 +202,12 @@ class HttpHandler(BaseHTTPRequestHandler): return form_data def render_template( - self, template, args_dict, status_code=200, version=__version__ + self, + template, + args_dict, + status_code=200, + version=__version__, + extra_headers=None, ): swap_client = self.server.swap_client if swap_client.ws_server: @@ -153,7 +218,6 @@ class HttpHandler(BaseHTTPRequestHandler): args_dict["debug_ui_mode"] = True if swap_client.use_tor_proxy: args_dict["use_tor_proxy"] = True - # TODO: Cache value? try: tor_state = get_tor_established_state(swap_client) args_dict["tor_established"] = True if tor_state == "1" else False @@ -202,7 +266,7 @@ class HttpHandler(BaseHTTPRequestHandler): args_dict["version"] = version - self.putHeaders(status_code, "text/html") + self.putHeaders(status_code, "text/html", extra_headers=extra_headers) return bytes( template.render( title=self.server.title, @@ -214,6 +278,7 @@ class HttpHandler(BaseHTTPRequestHandler): ) def render_simple_template(self, template, args_dict): + self.putHeaders(200, "text/html") return bytes( template.render( title=self.server.title, @@ -222,7 +287,7 @@ class HttpHandler(BaseHTTPRequestHandler): "UTF-8", ) - def page_info(self, info_str, post_string=None): + def page_info(self, info_str, post_string=None, extra_headers=None): template = env.get_template("info.html") swap_client = self.server.swap_client summary = swap_client.getSummary() @@ -233,6 +298,7 @@ class HttpHandler(BaseHTTPRequestHandler): "message_str": info_str, "summary": summary, }, + extra_headers=extra_headers, ) def page_error(self, error_str, post_string=None): @@ -248,6 +314,93 @@ class HttpHandler(BaseHTTPRequestHandler): }, ) + def page_login(self, url_split, post_string): + swap_client = self.server.swap_client + template = env.get_template("login.html") + err_messages = [] + extra_headers = [] + is_json_request = "application/json" in self.headers.get("Content-Type", "") + security_warning = None + if self.server.host_name not in ("127.0.0.1", "localhost"): + security_warning = "WARNING: Server is accessible on the network. Sending password over plain HTTP is insecure. Use HTTPS (e.g., via reverse proxy) for non-local access." + if not is_json_request: + err_messages.append(security_warning) + + if post_string: + password = None + if is_json_request: + try: + json_data = json.loads(post_string.decode("utf-8")) + password = json_data.get("password") + except Exception as e: + swap_client.log.error(f"Error parsing JSON login data: {e}") + else: + try: + form_data = parse.parse_qs(post_string.decode("utf-8")) + password = form_data.get("password", [None])[0] + except Exception as e: + swap_client.log.error(f"Error parsing form login data: {e}") + + client_auth_hash = swap_client.settings.get("client_auth_hash") + + if ( + client_auth_hash + and password is not None + and verify_rfc2440_password(client_auth_hash, password) + ): + session_id = secrets.token_urlsafe(32) + expires = datetime.now(timezone.utc) + timedelta( + minutes=SESSION_DURATION_MINUTES + ) + self.server.active_sessions[session_id] = {"expires": expires} + cookie_header = self._set_session_cookie(session_id) + + if is_json_request: + response_data = {"success": True, "session_id": session_id} + if security_warning: + response_data["warning"] = security_warning + self.putHeaders( + 200, "application/json", extra_headers=[cookie_header] + ) + return json.dumps(response_data).encode("utf-8") + else: + self.send_response(302) + self.send_header("Location", "/offers") + self.send_header(cookie_header[0], cookie_header[1]) + self.end_headers() + return b"" + else: + if is_json_request: + self.putHeaders(401, "application/json") + return json.dumps({"error": "Invalid password"}).encode("utf-8") + else: + err_messages.append("Invalid password.") + clear_cookie_header = self._clear_session_cookie() + extra_headers.append(clear_cookie_header) + + if ( + not is_json_request + and swap_client.settings.get("client_auth_hash") + and self.is_authenticated() + ): + self.send_response(302) + self.send_header("Location", "/offers") + self.end_headers() + return b"" + + return self.render_template( + template, + { + "title_str": "Login", + "err_messages": err_messages, + "summary": {}, + "encrypted": False, + "locked": False, + }, + status_code=401 if post_string and not is_json_request else 200, + extra_headers=extra_headers, + ) + def page_explorers(self, url_split, post_string): swap_client = self.server.swap_client swap_client.checkSystemStatus() @@ -261,14 +414,10 @@ class HttpHandler(BaseHTTPRequestHandler): form_data = self.checkForm(post_string, "explorers", err_messages) if form_data: - explorer = form_data[b"explorer"][0].decode("utf-8") - action = form_data[b"action"][0].decode("utf-8") + explorer = get_data_entry(form_data, "explorer") + action = get_data_entry(form_data, "action") + args = get_data_entry_or(form_data, "args", "") - args = ( - "" - if b"args" not in form_data - else form_data[b"args"][0].decode("utf-8") - ) try: c, e = explorer.split("_") exp = swap_client.coin_clients[Coins(int(c))]["explorers"][int(e)] @@ -457,6 +606,7 @@ class HttpHandler(BaseHTTPRequestHandler): def page_shutdown(self, url_split, post_string): swap_client = self.server.swap_client + extra_headers = [] if len(url_split) > 2: token = url_split[2] @@ -464,9 +614,15 @@ class HttpHandler(BaseHTTPRequestHandler): if token != expect_token: return self.page_info("Unexpected token, still running.") + session_id = self._get_session_cookie() + if session_id and session_id in self.server.active_sessions: + del self.server.active_sessions[session_id] + clear_cookie_header = self._clear_session_cookie() + extra_headers.append(clear_cookie_header) + swap_client.stopRunning() - return self.page_info("Shutting down") + return self.page_info("Shutting down", extra_headers=extra_headers) def page_index(self, url_split): swap_client = self.server.swap_client @@ -487,22 +643,81 @@ class HttpHandler(BaseHTTPRequestHandler): }, ) - def putHeaders(self, status_code, content_type): + def putHeaders(self, status_code, content_type, extra_headers=None): self.send_response(status_code) if self.server.allow_cors: self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Type", content_type) + if extra_headers: + for header_tuple in extra_headers: + self.send_header(header_tuple[0], header_tuple[1]) self.end_headers() def handle_http(self, status_code, path, post_string="", is_json=False): swap_client = self.server.swap_client parsed = parse.urlparse(self.path) url_split = parsed.path.split("/") - if post_string == "" and len(parsed.query) > 0: - post_string = parsed.query - if len(url_split) > 1 and url_split[1] == "json": + page = url_split[1] if len(url_split) > 1 else "" + + exempt_pages = ["login", "static", "error", "info"] + auth_header = self.headers.get("Authorization") + basic_auth_ok = False + + if auth_header and auth_header.startswith("Basic "): try: - self.putHeaders(status_code, "text/plain") + encoded_creds = auth_header.split(" ", 1)[1] + decoded_creds = base64.b64decode(encoded_creds).decode("utf-8") + _, password = decoded_creds.split(":", 1) + + client_auth_hash = swap_client.settings.get("client_auth_hash") + if client_auth_hash and verify_rfc2440_password( + client_auth_hash, password + ): + basic_auth_ok = True + else: + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="Basicswap"') + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({"error": "Invalid Basic Auth credentials"}).encode( + "utf-8" + ) + ) + return b"" + except Exception as e: + swap_client.log.error(f"Error processing Basic Auth header: {e}") + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="Basicswap"') + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({"error": "Malformed Basic Auth header"}).encode("utf-8") + ) + return b"" + + if not basic_auth_ok and page not in exempt_pages: + if not self.is_authenticated(): + if page == "json": + self.putHeaders(401, "application/json") + self.wfile.write( + json.dumps({"error": "Unauthorized"}).encode("utf-8") + ) + return b"" + else: + self.send_response(302) + self.send_header("Location", "/login") + clear_cookie_header = self._clear_session_cookie() + self.send_header(clear_cookie_header[0], clear_cookie_header[1]) + self.end_headers() + return b"" + + if not post_string and len(parsed.query) > 0: + post_string = parsed.query + + if page == "json": + try: + self.putHeaders(status_code, "json") func = js_url_to_function(url_split) return func(self, url_split, post_string, is_json) except Exception as ex: @@ -510,18 +725,20 @@ class HttpHandler(BaseHTTPRequestHandler): swap_client.log.error(traceback.format_exc()) return js_error(self, str(ex)) - if len(url_split) > 1 and url_split[1] == "static": + if page == "static": try: static_path = os.path.join(os.path.dirname(__file__), "static") + content = None + mime_type = "" + filepath = "" if len(url_split) > 3 and url_split[2] == "sequence_diagrams": - with open( - os.path.join(static_path, "sequence_diagrams", url_split[3]), - "rb", - ) as fp: - self.putHeaders(status_code, "image/svg+xml") - return fp.read() + filepath = os.path.join( + static_path, "sequence_diagrams", url_split[3] + ) + mime_type = "image/svg+xml" elif len(url_split) > 3 and url_split[2] == "images": filename = os.path.join(*url_split[3:]) + filepath = os.path.join(static_path, "images", filename) _, extension = os.path.splitext(filename) mime_type = { ".svg": "image/svg+xml", @@ -530,25 +747,25 @@ class HttpHandler(BaseHTTPRequestHandler): ".gif": "image/gif", ".ico": "image/x-icon", }.get(extension, "") - if mime_type == "": - raise ValueError("Unknown file type " + filename) - with open( - os.path.join(static_path, "images", filename), "rb" - ) as fp: - self.putHeaders(status_code, mime_type) - return fp.read() elif len(url_split) > 3 and url_split[2] == "css": filename = os.path.join(*url_split[3:]) - with open(os.path.join(static_path, "css", filename), "rb") as fp: - self.putHeaders(status_code, "text/css; charset=utf-8") - return fp.read() + filepath = os.path.join(static_path, "css", filename) + mime_type = "text/css; charset=utf-8" elif len(url_split) > 3 and url_split[2] == "js": filename = os.path.join(*url_split[3:]) - with open(os.path.join(static_path, "js", filename), "rb") as fp: - self.putHeaders(status_code, "application/javascript") - return fp.read() + filepath = os.path.join(static_path, "js", filename) + mime_type = "application/javascript" else: return self.page_404(url_split) + + if mime_type == "" or not filepath: + raise ValueError("Unknown file type or path") + + with open(filepath, "rb") as fp: + content = fp.read() + self.putHeaders(status_code, mime_type) + return content + except FileNotFoundError: return self.page_404(url_split) except Exception as ex: @@ -560,6 +777,8 @@ class HttpHandler(BaseHTTPRequestHandler): if len(url_split) > 1: page = url_split[1] + if page == "login": + return self.page_login(url_split, post_string) if page == "active": return self.page_active(url_split, post_string) if page == "wallets": @@ -632,7 +851,8 @@ class HttpHandler(BaseHTTPRequestHandler): self.server.swap_client.log.debug(f"do_GET SocketError {e}") def do_POST(self): - post_string = self.rfile.read(int(self.headers.get("Content-Length"))) + content_length = int(self.headers.get("Content-Length", 0)) + post_string = self.rfile.read(content_length) is_json = True if "json" in self.headers.get("Content-Type", "") else False response = self.handle_http(200, self.path, post_string, is_json) @@ -664,6 +884,7 @@ class HttpThread(threading.Thread, HTTPServer): self.title = "BasicSwap - " + __version__ self.last_form_id = dict() self.session_tokens = dict() + self.active_sessions = {} self.env = env self.msg_id_counter = 0 @@ -673,18 +894,19 @@ class HttpThread(threading.Thread, HTTPServer): def stop(self): self.stop_event.set() - # Send fake request - conn = http.client.HTTPConnection(self.host_name, self.port_no) - conn.connect() - conn.request("GET", "/none") - response = conn.getresponse() - _ = response.read() - conn.close() + try: + conn = http.client.HTTPConnection(self.host_name, self.port_no, timeout=0.5) + conn.request("GET", "/shutdown_ping") + conn.close() + except Exception: + pass def serve_forever(self): + self.timeout = 1 while not self.stop_event.is_set(): self.handle_request() self.socket.close() + self.swap_client.log.info("HTTP server stopped.") def run(self): self.serve_forever() diff --git a/basicswap/templates/login.html b/basicswap/templates/login.html new file mode 100644 index 0000000..130da26 --- /dev/null +++ b/basicswap/templates/login.html @@ -0,0 +1,72 @@ +{% from 'style.html' import circular_error_messages_svg %} + + + + + + + + + + (BSX) BasicSwap - Login - v{{ version }} + + +
+
+
+
+ + + + +

Login Required

+

Please enter the password to access BasicSwap.

+
+ + {% for m in err_messages %} + + {% endfor %} + +
+
+ + +
+ +

+ {{ title }} +

+
+
+
+
+ + + diff --git a/basicswap/util/rfc2440.py b/basicswap/util/rfc2440.py index 6b3343f..84eccff 100644 --- a/basicswap/util/rfc2440.py +++ b/basicswap/util/rfc2440.py @@ -29,3 +29,51 @@ def rfc2440_hash_password(password, salt=None): break rv = "16:" + salt.hex() + "60" + h.hexdigest() return rv.upper() + + +def verify_rfc2440_password(stored_hash, provided_password): + """ + Verifies a password against a hash generated by rfc2440_hash_password. + + Args: + stored_hash (str): The hash string stored (e.g., "16:60"). + provided_password (str): The password attempt to verify. + + Returns: + bool: True if the password matches the hash, False otherwise. + """ + try: + parts = stored_hash.upper().split(":") + if len(parts) != 2 or parts[0] != "16": + return False + + salt_hex_plus_hash_hex = parts[1] + separator_index = salt_hex_plus_hash_hex.find("60") + if separator_index != 16: + return False + + salt_hex = salt_hex_plus_hash_hex[:separator_index] + expected_hash_hex = salt_hex_plus_hash_hex[separator_index + 2 :] + + salt = bytes.fromhex(salt_hex) + except (ValueError, IndexError): + return False + + EXPBIAS = 6 + c = 96 + count = (16 + (c & 15)) << ((c >> 4) + EXPBIAS) + + hashbytes = salt + provided_password.encode("utf-8") + len_hashbytes = len(hashbytes) + h = hashlib.sha1() + + while count > 0: + if count >= len_hashbytes: + h.update(hashbytes) + count -= len_hashbytes + continue + h.update(hashbytes[:count]) + break + + calculated_hash_hex = h.hexdigest().upper() + return secrets.compare_digest(calculated_hash_hex, expected_hash_hex) diff --git a/doc/install.md b/doc/install.md index 082b66f..270e8f8 100644 --- a/doc/install.md +++ b/doc/install.md @@ -66,6 +66,10 @@ Adjust `--withcoins` and `--withoutcoins` as desired, eg: `--withcoins=monero,bi Append `--usebtcfastsync` to the below command to optionally initialise the Bitcoin datadir with a chain snapshot from btcpayserver FastSync.
[FastSync README.md](https://github.com/btcpayserver/btcpayserver-docker/blob/master/contrib/FastSync/README.md) +##### FastSync + +Append `--client-auth-password=` to the below command to optionally enable client authentication to protect your web UI from unauthorized access.
+ Setup with a local Monero daemon (recommended): @@ -200,7 +204,7 @@ Prepare the datadir: OR using a remote/public XMR daemon (not recommended): XMR_RPC_HOST="node.xmr.to" XMR_RPC_PORT=18081 basicswap-prepare --datadir=$SWAP_DATADIR --withcoins=monero --xmrrestoreheight=$CURRENT_XMR_HEIGHT - +Append `--client-auth-password=` to the above command to optionally enable client authentication to protect your web UI from unauthorized access.
Record the mnemonic from the output of the above command. Start Basicswap: diff --git a/scripts/createoffers.py b/scripts/createoffers.py index 46d00e3..50e8f64 100755 --- a/scripts/createoffers.py +++ b/scripts/createoffers.py @@ -18,6 +18,7 @@ Create offers "prune_state_delay": Seconds between pruning old state data, set to 0 to disable pruning. "main_loop_delay": Seconds between main loop iterations. "prune_state_after_seconds": Seconds to keep old state data for. + "auth": Basicswap API auth string, e.g., "admin:password". Ignored if client auth is not enabled. "offers": [ { "name": Offer template name, eg "Offer 0", will be automatically renamed if not unique. @@ -74,6 +75,8 @@ import threading import time import traceback import urllib +import urllib.error +import base64 from urllib.request import urlopen delay_event = threading.Event() @@ -85,29 +88,113 @@ DEFAULT_CONFIG_FILE: str = "createoffers.json" DEFAULT_STATE_FILE: str = "createoffers_state.json" -def post_req(url: str, json_data=None): +def post_req(url: str, json_data=None, auth_header_val=None): req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + if auth_header_val: + req.add_header("Authorization", auth_header_val) if json_data: req.add_header("Content-Type", "application/json; charset=utf-8") post_bytes = json.dumps(json_data).encode("utf-8") req.add_header("Content-Length", len(post_bytes)) else: post_bytes = None - return urlopen(req, data=post_bytes, timeout=300).read() + return urlopen(req, data=post_bytes, timeout=300) -def make_json_api_func(host: str, port: int): +def make_json_api_func(host: str, port: int, auth_string: str = None): host = host port = port + _auth_header_val = None + _auth_required_confirmed = False + if auth_string: + try: + if auth_string and ":" in auth_string: + try: + auth_bytes = auth_string.encode("utf-8") + _auth_header_val = "Basic " + base64.b64encode(auth_bytes).decode( + "ascii" + ) + except Exception as e: + print( + f"Warning: Could not process auth string '{auth_string}': {e}" + ) + _auth_header_val = None + elif auth_string: + print( + "Warning: Auth string is not in 'username:password' format. Ignoring." + ) + except Exception as e: + print(f"Error processing authentication: {e}") def api_func(path=None, json_data=None, timeout=300): + nonlocal _auth_required_confirmed url = f"http://{host}:{port}/json" if path is not None: url += "/" + path - if json_data is not None: - return json.loads(post_req(url, json_data)) - response = urlopen(url, timeout=300).read() - return json.loads(response) + + current_auth_header = _auth_header_val if _auth_required_confirmed else None + + try: + if json_data is not None: + response_obj = post_req( + url, json_data, auth_header_val=current_auth_header + ) + else: + headers = {"User-Agent": "Mozilla/5.0"} + if current_auth_header: + headers["Authorization"] = current_auth_header + req = urllib.request.Request(url, headers=headers) + response_obj = urlopen(req, timeout=timeout) + + response_bytes = response_obj.read() + return json.loads(response_bytes) + + except urllib.error.HTTPError as e: + if e.code == 401 and not _auth_required_confirmed: + if _auth_header_val: + print( + "Server requires authentication, retrying with credentials..." + ) + _auth_required_confirmed = True + try: + if json_data is not None: + response_obj = post_req( + url, json_data, auth_header_val=_auth_header_val + ) + else: + headers = { + "User-Agent": "Mozilla/5.0", + "Authorization": _auth_header_val, + } + req = urllib.request.Request(url, headers=headers) + response_obj = urlopen(req, timeout=timeout) + response_bytes = response_obj.read() + return json.loads(response_bytes) + except urllib.error.HTTPError as retry_e: + if retry_e.code == 401: + raise ValueError( + "Authentication failed: Invalid credentials provided in 'auth' key." + ) + else: + print(f"Error during authenticated API request: {retry_e}") + raise retry_e + except Exception as retry_e: + print(f"Error during authenticated API request: {retry_e}") + raise retry_e + else: + raise ValueError( + "Server requires authentication (401), but no 'auth' key found or properly formatted in config file." + ) + else: + if e.code == 401 and _auth_required_confirmed: + raise ValueError( + "Authentication failed: Invalid credentials provided in 'auth' key." + ) + else: + raise e + except Exception as e: + print(f"Error during API connection: {e}") + raise e return api_func @@ -820,14 +907,50 @@ def main(): ) args = parser.parse_args() - read_json_api = make_json_api_func(args.host, args.port) - if not os.path.exists(args.configfile): raise ValueError(f'Config file "{args.configfile}" not found.') + try: + with open(args.configfile) as fs: + initial_config = json.load(fs) + except Exception as e: + print(f"Error reading config file {args.configfile}: {e}") + sys.exit(1) - known_coins = read_json_api("coins") - for known_coin in known_coins: - coins_map[known_coin["name"]] = known_coin + auth_info = initial_config.get("auth") + + read_json_api = make_json_api_func(args.host, args.port, auth_info) + wallet_api_port_override = initial_config.get("wallet_port_override") + if wallet_api_port_override: + read_json_api_wallet_auth = make_json_api_func( + args.host, int(wallet_api_port_override), auth_info + ) + else: + read_json_api_wallet_auth = read_json_api_wallet + + try: + print("Checking API connection...") + known_coins = read_json_api("coins") + for known_coin in known_coins: + coins_map[known_coin["name"]] = known_coin + print("API connection successful.") + except ValueError as e: + print(f"\nError: {e}") + print( + 'Please ensure the \'auth\' key in your config file is correct (e.g., "auth": "username:password")' + ) + sys.exit(1) + except urllib.error.URLError as e: + print( + f"\nError: Could not connect to Basicswap API at http://{args.host}:{args.port}" + ) + print(f"Reason: {e.reason}") + print("Please ensure Basicswap is running and accessible.") + sys.exit(1) + except Exception as e: + print(f"\nError during initial API connection: {e}") + if args.debug: + traceback.print_exc() + sys.exit(1) script_state = {} if os.path.exists(args.statefile): @@ -843,7 +966,7 @@ def main(): if "wallet_port_override" in config: wallet_api_port = int(config["wallet_port_override"]) print(f"Overriding wallet api port: {wallet_api_port}") - read_json_api_wallet = make_json_api_func(args.host, wallet_api_port) + read_json_api_wallet = read_json_api_wallet_auth else: read_json_api_wallet = read_json_api diff --git a/scripts/template_createoffers.json b/scripts/template_createoffers.json index e18a3d3..7b1dd9b 100644 --- a/scripts/template_createoffers.json +++ b/scripts/template_createoffers.json @@ -1,4 +1,5 @@ { + "auth": "admin:password", "offers": [ { "enabled": true,