mirror of
https://github.com/basicswap/basicswap.git
synced 2025-11-05 10:28:10 +01:00
Added client authentication
This commit is contained in:
@@ -1711,6 +1711,8 @@ def printHelp():
|
|||||||
print(
|
print(
|
||||||
"--dashv20compatible Generate the same DASH wallet seed as for DASH v20 - Use only when importing an existing seed."
|
"--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 = []
|
active_coins = []
|
||||||
for coin_name in known_coins.keys():
|
for coin_name in known_coins.keys():
|
||||||
@@ -2114,6 +2116,8 @@ def main():
|
|||||||
disable_tor = False
|
disable_tor = False
|
||||||
initwalletsonly = False
|
initwalletsonly = False
|
||||||
tor_control_password = None
|
tor_control_password = None
|
||||||
|
client_auth_pwd_value = None
|
||||||
|
disable_client_auth_flag = False
|
||||||
extra_opts = {}
|
extra_opts = {}
|
||||||
|
|
||||||
if os.getenv("SSL_CERT_DIR", "") == "" and GUIX_SSL_CERT_DIR is not None:
|
if os.getenv("SSL_CERT_DIR", "") == "" and GUIX_SSL_CERT_DIR is not None:
|
||||||
@@ -2246,7 +2250,15 @@ def main():
|
|||||||
if name == "trustremotenode":
|
if name == "trustremotenode":
|
||||||
extra_opts["trust_remote_node"] = toBool(s[1])
|
extra_opts["trust_remote_node"] = toBool(s[1])
|
||||||
continue
|
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))
|
exitWithError("Unknown argument {}".format(v))
|
||||||
|
|
||||||
if print_versions:
|
if print_versions:
|
||||||
@@ -2276,6 +2288,34 @@ def main():
|
|||||||
os.makedirs(data_dir)
|
os.makedirs(data_dir)
|
||||||
config_path = os.path.join(data_dir, cfg.CONFIG_FILENAME)
|
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):
|
if use_tor_proxy and extra_opts.get("no_tor_proxy", False):
|
||||||
exitWithError("Can't use --usetorproxy and --notorproxy together")
|
exitWithError("Can't use --usetorproxy and --notorproxy together")
|
||||||
|
|
||||||
@@ -2916,6 +2956,10 @@ def main():
|
|||||||
tor_control_password = generate_salt(24)
|
tor_control_password = generate_salt(24)
|
||||||
addTorSettings(settings, tor_control_password)
|
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:
|
if not no_cores:
|
||||||
for c in with_coins:
|
for c in with_coins:
|
||||||
prepareCore(c, known_coins[c], settings, data_dir, extra_opts)
|
prepareCore(c, known_coins[c], settings, data_dir, extra_opts)
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# Copyright (c) 2019-2024 tecnovert
|
# 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
|
# Distributed under the MIT software license, see the accompanying
|
||||||
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
|
# file LICENSE or http://www.opensource.org/licenses/mit-license.php.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import shlex
|
import shlex
|
||||||
|
import secrets
|
||||||
import traceback
|
import traceback
|
||||||
import threading
|
import threading
|
||||||
import http.client
|
import http.client
|
||||||
|
import base64
|
||||||
|
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from jinja2 import Environment, PackageLoader
|
from jinja2 import Environment, PackageLoader
|
||||||
from socket import error as SocketError
|
from socket import error as SocketError
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .util import (
|
from .util import (
|
||||||
@@ -32,14 +36,14 @@ from .basicswap_util import (
|
|||||||
strTxState,
|
strTxState,
|
||||||
strBidState,
|
strBidState,
|
||||||
)
|
)
|
||||||
|
from .util.rfc2440 import verify_rfc2440_password
|
||||||
|
|
||||||
from .js_server import (
|
from .js_server import (
|
||||||
js_error,
|
js_error,
|
||||||
js_url_to_function,
|
js_url_to_function,
|
||||||
)
|
)
|
||||||
from .ui.util import (
|
from .ui.util import (
|
||||||
getCoinName,
|
getCoinName,
|
||||||
get_data_entry,
|
|
||||||
get_data_entry_or,
|
|
||||||
listAvailableCoins,
|
listAvailableCoins,
|
||||||
)
|
)
|
||||||
from .ui.page_automation import (
|
from .ui.page_automation import (
|
||||||
@@ -58,6 +62,9 @@ from .ui.page_identity import page_identity
|
|||||||
from .ui.page_smsgaddresses import page_smsgaddresses
|
from .ui.page_smsgaddresses import page_smsgaddresses
|
||||||
from .ui.page_debug import page_debug
|
from .ui.page_debug import page_debug
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = "basicswap_session_id"
|
||||||
|
SESSION_DURATION_MINUTES = 60
|
||||||
|
|
||||||
env = Environment(loader=PackageLoader("basicswap", "templates"))
|
env = Environment(loader=PackageLoader("basicswap", "templates"))
|
||||||
env.filters["formatts"] = format_timestamp
|
env.filters["formatts"] = format_timestamp
|
||||||
|
|
||||||
@@ -120,6 +127,57 @@ def parse_cmd(cmd: str, type_map: str):
|
|||||||
|
|
||||||
|
|
||||||
class HttpHandler(BaseHTTPRequestHandler):
|
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):
|
def log_error(self, format, *args):
|
||||||
super().log_message(format, *args)
|
super().log_message(format, *args)
|
||||||
|
|
||||||
@@ -131,18 +189,32 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
return os.urandom(8).hex()
|
return os.urandom(8).hex()
|
||||||
|
|
||||||
def checkForm(self, post_string, name, messages):
|
def checkForm(self, post_string, name, messages):
|
||||||
if post_string == "":
|
if not post_string:
|
||||||
return None
|
return None
|
||||||
form_data = parse.parse_qs(post_string)
|
post_data_str = (
|
||||||
form_id = form_data[b"formid"][0].decode("utf-8")
|
post_string.decode("utf-8")
|
||||||
if self.server.last_form_id.get(name, None) == form_id:
|
if isinstance(post_string, bytes)
|
||||||
messages.append("Prevented double submit for form {}.".format(form_id))
|
else post_string
|
||||||
|
)
|
||||||
|
form_data = parse.parse_qs(post_data_str)
|
||||||
|
form_id_list = form_data.get("formid", [])
|
||||||
|
if not form_id_list:
|
||||||
|
return form_data
|
||||||
|
|
||||||
|
form_id = form_id_list[0]
|
||||||
|
if form_id == self.server.last_form_id.get(name):
|
||||||
|
messages.append("Form already submitted.")
|
||||||
return None
|
return None
|
||||||
self.server.last_form_id[name] = form_id
|
self.server.last_form_id[name] = form_id
|
||||||
return form_data
|
return form_data
|
||||||
|
|
||||||
def render_template(
|
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
|
swap_client = self.server.swap_client
|
||||||
if swap_client.ws_server:
|
if swap_client.ws_server:
|
||||||
@@ -153,7 +225,6 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
args_dict["debug_ui_mode"] = True
|
args_dict["debug_ui_mode"] = True
|
||||||
if swap_client.use_tor_proxy:
|
if swap_client.use_tor_proxy:
|
||||||
args_dict["use_tor_proxy"] = True
|
args_dict["use_tor_proxy"] = True
|
||||||
# TODO: Cache value?
|
|
||||||
try:
|
try:
|
||||||
tor_state = get_tor_established_state(swap_client)
|
tor_state = get_tor_established_state(swap_client)
|
||||||
args_dict["tor_established"] = True if tor_state == "1" else False
|
args_dict["tor_established"] = True if tor_state == "1" else False
|
||||||
@@ -202,7 +273,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
args_dict["version"] = version
|
args_dict["version"] = version
|
||||||
|
|
||||||
self.putHeaders(status_code, "text/html")
|
self.putHeaders(status_code, "text/html", extra_headers=extra_headers)
|
||||||
return bytes(
|
return bytes(
|
||||||
template.render(
|
template.render(
|
||||||
title=self.server.title,
|
title=self.server.title,
|
||||||
@@ -214,6 +285,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def render_simple_template(self, template, args_dict):
|
def render_simple_template(self, template, args_dict):
|
||||||
|
self.putHeaders(200, "text/html")
|
||||||
return bytes(
|
return bytes(
|
||||||
template.render(
|
template.render(
|
||||||
title=self.server.title,
|
title=self.server.title,
|
||||||
@@ -222,7 +294,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
"UTF-8",
|
"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")
|
template = env.get_template("info.html")
|
||||||
swap_client = self.server.swap_client
|
swap_client = self.server.swap_client
|
||||||
summary = swap_client.getSummary()
|
summary = swap_client.getSummary()
|
||||||
@@ -233,6 +305,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
"message_str": info_str,
|
"message_str": info_str,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
},
|
},
|
||||||
|
extra_headers=extra_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
def page_error(self, error_str, post_string=None):
|
def page_error(self, error_str, post_string=None):
|
||||||
@@ -248,6 +321,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):
|
def page_explorers(self, url_split, post_string):
|
||||||
swap_client = self.server.swap_client
|
swap_client = self.server.swap_client
|
||||||
swap_client.checkSystemStatus()
|
swap_client.checkSystemStatus()
|
||||||
@@ -259,16 +419,12 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
messages = []
|
messages = []
|
||||||
err_messages = []
|
err_messages = []
|
||||||
form_data = self.checkForm(post_string, "explorers", err_messages)
|
form_data = self.checkForm(post_string, "explorers", err_messages)
|
||||||
if form_data:
|
if form_data and form_data is not None:
|
||||||
|
|
||||||
explorer = form_data[b"explorer"][0].decode("utf-8")
|
explorer = form_data.get("explorer", ["-1"])[0]
|
||||||
action = form_data[b"action"][0].decode("utf-8")
|
action = form_data.get("action", ["-1"])[0]
|
||||||
|
args = form_data.get("args", [""])[0]
|
||||||
|
|
||||||
args = (
|
|
||||||
""
|
|
||||||
if b"args" not in form_data
|
|
||||||
else form_data[b"args"][0].decode("utf-8")
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
c, e = explorer.split("_")
|
c, e = explorer.split("_")
|
||||||
exp = swap_client.coin_clients[Coins(int(c))]["explorers"][int(e)]
|
exp = swap_client.coin_clients[Coins(int(c))]["explorers"][int(e)]
|
||||||
@@ -316,12 +472,12 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
messages = []
|
messages = []
|
||||||
err_messages = []
|
err_messages = []
|
||||||
form_data = self.checkForm(post_string, "rpc", err_messages)
|
form_data = self.checkForm(post_string, "rpc", err_messages)
|
||||||
if form_data:
|
if form_data and form_data is not None: # Check if not None (double submit)
|
||||||
try:
|
try:
|
||||||
call_type = get_data_entry_or(form_data, "call_type", "cli")
|
call_type = form_data.get("call_type", ["cli"])[0]
|
||||||
type_map = get_data_entry_or(form_data, "type_map", "")
|
type_map = form_data.get("type_map", [""])[0]
|
||||||
try:
|
try:
|
||||||
coin_type_selected = get_data_entry(form_data, "coin_type")
|
coin_type_selected = form_data.get("coin_type", ["-1"])[0]
|
||||||
coin_type_split = coin_type_selected.split(",")
|
coin_type_split = coin_type_selected.split(",")
|
||||||
coin_type = Coins(int(coin_type_split[0]))
|
coin_type = Coins(int(coin_type_split[0]))
|
||||||
coin_variant = int(coin_type_split[1])
|
coin_variant = int(coin_type_split[1])
|
||||||
@@ -332,7 +488,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
call_type = "http"
|
call_type = "http"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cmd = get_data_entry(form_data, "cmd")
|
cmd = form_data.get("cmd", [""])[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ValueError("Invalid command")
|
raise ValueError("Invalid command")
|
||||||
if coin_type in (Coins.XMR, Coins.WOW):
|
if coin_type in (Coins.XMR, Coins.WOW):
|
||||||
@@ -457,6 +613,7 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def page_shutdown(self, url_split, post_string):
|
def page_shutdown(self, url_split, post_string):
|
||||||
swap_client = self.server.swap_client
|
swap_client = self.server.swap_client
|
||||||
|
extra_headers = []
|
||||||
|
|
||||||
if len(url_split) > 2:
|
if len(url_split) > 2:
|
||||||
token = url_split[2]
|
token = url_split[2]
|
||||||
@@ -464,9 +621,15 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
if token != expect_token:
|
if token != expect_token:
|
||||||
return self.page_info("Unexpected token, still running.")
|
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()
|
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):
|
def page_index(self, url_split):
|
||||||
swap_client = self.server.swap_client
|
swap_client = self.server.swap_client
|
||||||
@@ -487,22 +650,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)
|
self.send_response(status_code)
|
||||||
if self.server.allow_cors:
|
if self.server.allow_cors:
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
self.send_header("Content-Type", content_type)
|
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()
|
self.end_headers()
|
||||||
|
|
||||||
def handle_http(self, status_code, path, post_string="", is_json=False):
|
def handle_http(self, status_code, path, post_string=b"", is_json=False):
|
||||||
swap_client = self.server.swap_client
|
swap_client = self.server.swap_client
|
||||||
parsed = parse.urlparse(self.path)
|
parsed = parse.urlparse(self.path)
|
||||||
url_split = parsed.path.split("/")
|
url_split = parsed.path.split("/")
|
||||||
if post_string == "" and len(parsed.query) > 0:
|
page = url_split[1] if len(url_split) > 1 else ""
|
||||||
post_string = parsed.query
|
|
||||||
if len(url_split) > 1 and url_split[1] == "json":
|
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:
|
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.encode("utf-8")
|
||||||
|
|
||||||
|
if page == "json":
|
||||||
|
try:
|
||||||
|
self.putHeaders(status_code, "json")
|
||||||
func = js_url_to_function(url_split)
|
func = js_url_to_function(url_split)
|
||||||
return func(self, url_split, post_string, is_json)
|
return func(self, url_split, post_string, is_json)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@@ -510,18 +732,20 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
swap_client.log.error(traceback.format_exc())
|
swap_client.log.error(traceback.format_exc())
|
||||||
return js_error(self, str(ex))
|
return js_error(self, str(ex))
|
||||||
|
|
||||||
if len(url_split) > 1 and url_split[1] == "static":
|
if page == "static":
|
||||||
try:
|
try:
|
||||||
static_path = os.path.join(os.path.dirname(__file__), "static")
|
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":
|
if len(url_split) > 3 and url_split[2] == "sequence_diagrams":
|
||||||
with open(
|
filepath = os.path.join(
|
||||||
os.path.join(static_path, "sequence_diagrams", url_split[3]),
|
static_path, "sequence_diagrams", url_split[3]
|
||||||
"rb",
|
)
|
||||||
) as fp:
|
mime_type = "image/svg+xml"
|
||||||
self.putHeaders(status_code, "image/svg+xml")
|
|
||||||
return fp.read()
|
|
||||||
elif len(url_split) > 3 and url_split[2] == "images":
|
elif len(url_split) > 3 and url_split[2] == "images":
|
||||||
filename = os.path.join(*url_split[3:])
|
filename = os.path.join(*url_split[3:])
|
||||||
|
filepath = os.path.join(static_path, "images", filename)
|
||||||
_, extension = os.path.splitext(filename)
|
_, extension = os.path.splitext(filename)
|
||||||
mime_type = {
|
mime_type = {
|
||||||
".svg": "image/svg+xml",
|
".svg": "image/svg+xml",
|
||||||
@@ -530,25 +754,25 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".ico": "image/x-icon",
|
".ico": "image/x-icon",
|
||||||
}.get(extension, "")
|
}.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":
|
elif len(url_split) > 3 and url_split[2] == "css":
|
||||||
filename = os.path.join(*url_split[3:])
|
filename = os.path.join(*url_split[3:])
|
||||||
with open(os.path.join(static_path, "css", filename), "rb") as fp:
|
filepath = os.path.join(static_path, "css", filename)
|
||||||
self.putHeaders(status_code, "text/css; charset=utf-8")
|
mime_type = "text/css; charset=utf-8"
|
||||||
return fp.read()
|
|
||||||
elif len(url_split) > 3 and url_split[2] == "js":
|
elif len(url_split) > 3 and url_split[2] == "js":
|
||||||
filename = os.path.join(*url_split[3:])
|
filename = os.path.join(*url_split[3:])
|
||||||
with open(os.path.join(static_path, "js", filename), "rb") as fp:
|
filepath = os.path.join(static_path, "js", filename)
|
||||||
self.putHeaders(status_code, "application/javascript")
|
mime_type = "application/javascript"
|
||||||
return fp.read()
|
|
||||||
else:
|
else:
|
||||||
return self.page_404(url_split)
|
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:
|
except FileNotFoundError:
|
||||||
return self.page_404(url_split)
|
return self.page_404(url_split)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@@ -560,6 +784,8 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
if len(url_split) > 1:
|
if len(url_split) > 1:
|
||||||
page = url_split[1]
|
page = url_split[1]
|
||||||
|
|
||||||
|
if page == "login":
|
||||||
|
return self.page_login(url_split, post_string)
|
||||||
if page == "active":
|
if page == "active":
|
||||||
return self.page_active(url_split, post_string)
|
return self.page_active(url_split, post_string)
|
||||||
if page == "wallets":
|
if page == "wallets":
|
||||||
@@ -632,7 +858,8 @@ class HttpHandler(BaseHTTPRequestHandler):
|
|||||||
self.server.swap_client.log.debug(f"do_GET SocketError {e}")
|
self.server.swap_client.log.debug(f"do_GET SocketError {e}")
|
||||||
|
|
||||||
def do_POST(self):
|
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
|
is_json = True if "json" in self.headers.get("Content-Type", "") else False
|
||||||
response = self.handle_http(200, self.path, post_string, is_json)
|
response = self.handle_http(200, self.path, post_string, is_json)
|
||||||
@@ -664,6 +891,7 @@ class HttpThread(threading.Thread, HTTPServer):
|
|||||||
self.title = "BasicSwap - " + __version__
|
self.title = "BasicSwap - " + __version__
|
||||||
self.last_form_id = dict()
|
self.last_form_id = dict()
|
||||||
self.session_tokens = dict()
|
self.session_tokens = dict()
|
||||||
|
self.active_sessions = {}
|
||||||
self.env = env
|
self.env = env
|
||||||
self.msg_id_counter = 0
|
self.msg_id_counter = 0
|
||||||
|
|
||||||
@@ -673,18 +901,19 @@ class HttpThread(threading.Thread, HTTPServer):
|
|||||||
def stop(self):
|
def stop(self):
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
|
|
||||||
# Send fake request
|
try:
|
||||||
conn = http.client.HTTPConnection(self.host_name, self.port_no)
|
conn = http.client.HTTPConnection(self.host_name, self.port_no, timeout=0.5)
|
||||||
conn.connect()
|
conn.request("GET", "/shutdown_ping")
|
||||||
conn.request("GET", "/none")
|
|
||||||
response = conn.getresponse()
|
|
||||||
_ = response.read()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def serve_forever(self):
|
def serve_forever(self):
|
||||||
|
self.timeout = 1
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
self.handle_request()
|
self.handle_request()
|
||||||
self.socket.close()
|
self.socket.close()
|
||||||
|
self.swap_client.log.info("HTTP server stopped.")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self.serve_forever()
|
self.serve_forever()
|
||||||
|
|||||||
72
basicswap/templates/login.html
Normal file
72
basicswap/templates/login.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{% from 'style.html' import circular_error_messages_svg %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link type="text/css" media="all" href="/static/css/libs/flowbite.min.css" rel="stylesheet" />
|
||||||
|
<link type="text/css" media="all" href="/static/css/libs/tailwind.min.css" rel="stylesheet">
|
||||||
|
<link type="text/css" media="all" href="/static/css/style.css" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
const isDarkMode = localStorage.getItem('color-theme') === 'dark' || (!localStorage.getItem('color-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (isDarkMode) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel=icon sizes="32x32" type="image/png" href="/static/images/favicon/favicon-32.png">
|
||||||
|
<title>(BSX) BasicSwap - Login - v{{ version }}</title>
|
||||||
|
</head>
|
||||||
|
<body class="dark:bg-gray-700">
|
||||||
|
<section class="py-24 md:py-32">
|
||||||
|
<div class="container px-4 mx-auto">
|
||||||
|
<div class="max-w-sm mx-auto">
|
||||||
|
<div class="mb-6 text-center">
|
||||||
|
<a class="inline-block mb-6" href="#">
|
||||||
|
<img src="/static/images/logos/basicswap-logo.svg" class="h-20 imageshow dark-image" style="display: none;">
|
||||||
|
<img src="/static/images/logos/basicswap-logo-dark.svg" class="h-20 imageshow light-image" style="display: block;">
|
||||||
|
</a>
|
||||||
|
<h3 class="mb-4 text-2xl md:text-3xl font-bold dark:text-white">Login Required</h3>
|
||||||
|
<p class="text-lg text-coolGray-500 font-medium dark:text-gray-300">Please enter the password to access BasicSwap.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for m in err_messages %}
|
||||||
|
<section class="py-4" id="err_messages_{{ m[0] }}" role="alert">
|
||||||
|
<div class="container px-4 mx-auto">
|
||||||
|
<div class="p-4 text-red-800 rounded-lg bg-red-50 border border-red-400 dark:bg-gray-600 dark:text-red-300 rounded-md">
|
||||||
|
<div class="flex flex-wrap items-center -m-1">
|
||||||
|
<div class="w-auto p-1"> {{ circular_error_messages_svg | safe }} </div>
|
||||||
|
<p class="ml-2 font-medium text-sm">{{ m[1] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<form method="post" action="/login" autocomplete="off">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block mb-2 text-coolGray-800 font-medium dark:text-white" for="password">Password</label>
|
||||||
|
<input class="appearance-none block w-full p-3 leading-5 text-coolGray-900 border border-coolGray-200 rounded-lg shadow-md placeholder-coolGray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-600 dark:border-gray-500 dark:text-white"
|
||||||
|
type="password" name="password" id="password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button class="inline-block py-3 px-7 mb-6 w-full text-base text-blue-50 font-medium text-center leading-6 bg-blue-500 hover:bg-blue-600 rounded-md shadow-sm"
|
||||||
|
type="submit">Login</button>
|
||||||
|
<p class="text-center">
|
||||||
|
<span class="text-xs font-medium text-coolGray-500 dark:text-gray-500">{{ title }}</span>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
function toggleImages() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
const darkImages = document.querySelectorAll('.dark-image');
|
||||||
|
const lightImages = document.querySelectorAll('.light-image');
|
||||||
|
darkImages.forEach(img => img.style.display = isDark ? 'block' : 'none');
|
||||||
|
lightImages.forEach(img => img.style.display = isDark ? 'none' : 'block');
|
||||||
|
}
|
||||||
|
toggleImages();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -29,3 +29,51 @@ def rfc2440_hash_password(password, salt=None):
|
|||||||
break
|
break
|
||||||
rv = "16:" + salt.hex() + "60" + h.hexdigest()
|
rv = "16:" + salt.hex() + "60" + h.hexdigest()
|
||||||
return rv.upper()
|
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:<salt>60<hash>").
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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.<br>
|
Append `--usebtcfastsync` to the below command to optionally initialise the Bitcoin datadir with a chain snapshot from btcpayserver FastSync.<br>
|
||||||
[FastSync README.md](https://github.com/btcpayserver/btcpayserver-docker/blob/master/contrib/FastSync/README.md)
|
[FastSync README.md](https://github.com/btcpayserver/btcpayserver-docker/blob/master/contrib/FastSync/README.md)
|
||||||
|
|
||||||
|
##### FastSync
|
||||||
|
|
||||||
|
Append `--client-auth-password=<YOUR_PASSWORD>` to the below command to optionally enable client authentication to protect your web UI from unauthorized access.<br>
|
||||||
|
|
||||||
|
|
||||||
Setup with a local Monero daemon (recommended):
|
Setup with a local Monero daemon (recommended):
|
||||||
|
|
||||||
@@ -200,7 +204,7 @@ Prepare the datadir:
|
|||||||
OR using a remote/public XMR daemon (not recommended):
|
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
|
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=<YOUR_PASSWORD>` to the above command to optionally enable client authentication to protect your web UI from unauthorized access.<br>
|
||||||
Record the mnemonic from the output of the above command.
|
Record the mnemonic from the output of the above command.
|
||||||
|
|
||||||
Start Basicswap:
|
Start Basicswap:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Create offers
|
|||||||
"prune_state_delay": Seconds between pruning old state data, set to 0 to disable pruning.
|
"prune_state_delay": Seconds between pruning old state data, set to 0 to disable pruning.
|
||||||
"main_loop_delay": Seconds between main loop iterations.
|
"main_loop_delay": Seconds between main loop iterations.
|
||||||
"prune_state_after_seconds": Seconds to keep old state data for.
|
"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": [
|
"offers": [
|
||||||
{
|
{
|
||||||
"name": Offer template name, eg "Offer 0", will be automatically renamed if not unique.
|
"name": Offer template name, eg "Offer 0", will be automatically renamed if not unique.
|
||||||
@@ -74,6 +75,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import urllib
|
import urllib
|
||||||
|
import urllib.error
|
||||||
|
import base64
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
|
|
||||||
delay_event = threading.Event()
|
delay_event = threading.Event()
|
||||||
@@ -85,29 +88,113 @@ DEFAULT_CONFIG_FILE: str = "createoffers.json"
|
|||||||
DEFAULT_STATE_FILE: str = "createoffers_state.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"})
|
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:
|
if json_data:
|
||||||
req.add_header("Content-Type", "application/json; charset=utf-8")
|
req.add_header("Content-Type", "application/json; charset=utf-8")
|
||||||
post_bytes = json.dumps(json_data).encode("utf-8")
|
post_bytes = json.dumps(json_data).encode("utf-8")
|
||||||
req.add_header("Content-Length", len(post_bytes))
|
req.add_header("Content-Length", len(post_bytes))
|
||||||
else:
|
else:
|
||||||
post_bytes = None
|
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
|
host = host
|
||||||
port = port
|
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):
|
def api_func(path=None, json_data=None, timeout=300):
|
||||||
|
nonlocal _auth_required_confirmed
|
||||||
url = f"http://{host}:{port}/json"
|
url = f"http://{host}:{port}/json"
|
||||||
if path is not None:
|
if path is not None:
|
||||||
url += "/" + path
|
url += "/" + path
|
||||||
|
|
||||||
|
current_auth_header = _auth_header_val if _auth_required_confirmed else None
|
||||||
|
|
||||||
|
try:
|
||||||
if json_data is not None:
|
if json_data is not None:
|
||||||
return json.loads(post_req(url, json_data))
|
response_obj = post_req(
|
||||||
response = urlopen(url, timeout=300).read()
|
url, json_data, auth_header_val=current_auth_header
|
||||||
return json.loads(response)
|
)
|
||||||
|
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
|
return api_func
|
||||||
|
|
||||||
@@ -820,14 +907,50 @@ def main():
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
read_json_api = make_json_api_func(args.host, args.port)
|
|
||||||
|
|
||||||
if not os.path.exists(args.configfile):
|
if not os.path.exists(args.configfile):
|
||||||
raise ValueError(f'Config file "{args.configfile}" not found.')
|
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)
|
||||||
|
|
||||||
|
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")
|
known_coins = read_json_api("coins")
|
||||||
for known_coin in known_coins:
|
for known_coin in known_coins:
|
||||||
coins_map[known_coin["name"]] = known_coin
|
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 = {}
|
script_state = {}
|
||||||
if os.path.exists(args.statefile):
|
if os.path.exists(args.statefile):
|
||||||
@@ -843,7 +966,7 @@ def main():
|
|||||||
if "wallet_port_override" in config:
|
if "wallet_port_override" in config:
|
||||||
wallet_api_port = int(config["wallet_port_override"])
|
wallet_api_port = int(config["wallet_port_override"])
|
||||||
print(f"Overriding wallet api port: {wallet_api_port}")
|
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:
|
else:
|
||||||
read_json_api_wallet = read_json_api
|
read_json_api_wallet = read_json_api
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"auth": "admin:password",
|
||||||
"offers": [
|
"offers": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user