Added client authentication

This commit is contained in:
cryptoguard 2025-04-01 00:24:32 -04:00
parent 9c252323be
commit 01b97b6967
5 changed files with 393 additions and 60 deletions

View file

@ -1711,6 +1711,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():
@ -2114,6 +2116,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:
@ -2246,7 +2250,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:
@ -2276,6 +2288,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")
@ -2916,6 +2956,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)

View file

@ -1,13 +1,15 @@
# -*- 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 cgi
import json
import shlex
import secrets
import traceback
import threading
import http.client
@ -16,6 +18,8 @@ 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,14 +36,14 @@ from .basicswap_util import (
strTxState,
strBidState,
)
from .util.rfc2440 import verify_rfc2440_password
from .js_server import (
js_error,
js_url_to_function,
)
from .ui.util import (
getCoinName,
get_data_entry,
get_data_entry_or,
listAvailableCoins,
)
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_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 +127,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)
@ -131,18 +189,32 @@ class HttpHandler(BaseHTTPRequestHandler):
return os.urandom(8).hex()
def checkForm(self, post_string, name, messages):
if post_string == "":
if not post_string:
return None
form_data = parse.parse_qs(post_string)
form_id = form_data[b"formid"][0].decode("utf-8")
if self.server.last_form_id.get(name, None) == form_id:
messages.append("Prevented double submit for form {}.".format(form_id))
post_data_str = (
post_string.decode("utf-8")
if isinstance(post_string, bytes)
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
self.server.last_form_id[name] = form_id
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 +225,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 +273,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 +285,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 +294,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 +305,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 +321,68 @@ 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 = []
if post_string:
from io import BytesIO
fs = cgi.FieldStorage(
fp=BytesIO(post_string),
headers=self.headers,
environ={
"REQUEST_METHOD": "POST",
"CONTENT_TYPE": self.headers["Content-Type"],
},
)
password = fs.getvalue("password")
client_auth_hash = swap_client.settings.get("client_auth_hash")
if (
client_auth_hash
and password
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)
self.send_response(302)
self.send_header("Location", "/offers")
self.send_header(cookie_header[0], cookie_header[1])
self.end_headers()
return b""
else:
err_messages.append("Invalid password.")
clear_cookie_header = self._clear_session_cookie()
extra_headers.append(clear_cookie_header)
if 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 err_messages else 200,
extra_headers=extra_headers,
)
def page_explorers(self, url_split, post_string):
swap_client = self.server.swap_client
swap_client.checkSystemStatus()
@ -259,16 +394,12 @@ class HttpHandler(BaseHTTPRequestHandler):
messages = []
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")
action = form_data[b"action"][0].decode("utf-8")
explorer = form_data.get("explorer", ["-1"])[0]
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:
c, e = explorer.split("_")
exp = swap_client.coin_clients[Coins(int(c))]["explorers"][int(e)]
@ -316,12 +447,12 @@ class HttpHandler(BaseHTTPRequestHandler):
messages = []
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:
call_type = get_data_entry_or(form_data, "call_type", "cli")
type_map = get_data_entry_or(form_data, "type_map", "")
call_type = form_data.get("call_type", ["cli"])[0]
type_map = form_data.get("type_map", [""])[0]
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 = Coins(int(coin_type_split[0]))
coin_variant = int(coin_type_split[1])
@ -332,7 +463,7 @@ class HttpHandler(BaseHTTPRequestHandler):
call_type = "http"
try:
cmd = get_data_entry(form_data, "cmd")
cmd = form_data.get("cmd", [""])[0]
except Exception:
raise ValueError("Invalid command")
if coin_type in (Coins.XMR, Coins.WOW):
@ -457,6 +588,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 +596,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,20 +625,36 @@ 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):
def handle_http(self, status_code, path, post_string=b"", 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", "json", "error", "info"]
if page not in exempt_pages:
if not self.is_authenticated():
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, "text/plain")
func = js_url_to_function(url_split)
@ -508,20 +662,23 @@ class HttpHandler(BaseHTTPRequestHandler):
except Exception as ex:
if swap_client.debug is True:
swap_client.log.error(traceback.format_exc())
self.putHeaders(500, "text/plain")
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 +687,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 +717,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 +791,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 +824,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 +834,22 @@ 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.swap_client.log.info(
f"Starting HTTP server on {self.host_name}:{self.port_no}"
)
self.serve_forever()

View 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>

View file

@ -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:<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)

View file

@ -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>
[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):
@ -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=<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.
Start Basicswap: