Skip to content

Commit 22b4fcc

Browse files
author
Ivan
committed
feat: add demo mode and ALTCHA integration for better security for internet deployments and showcase functionality
1 parent 5745d53 commit 22b4fcc

51 files changed

Lines changed: 1201 additions & 110 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docker-compose.demo.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Coolify public demo stack (read-only mesh showcase, ALTCHA, session auth).
2+
# https://coolify.io/docs/knowledge-base/docker/compose
3+
#
4+
# Set MESHCHAT_ALTCHA_HMAC_KEY in Coolify before deploy (required below).
5+
# Showcase login password defaults to demo (override MESHCHAT_DEMO_AUTH_PASSWORD).
6+
#
7+
# Assign a domain in Coolify with container port 8000, for example:
8+
# https://meshchatx.example.com:8000
9+
# The :8000 suffix is the container port for the proxy, not the public URL port.
10+
#
11+
# Coolify terminates TLS at the proxy. Plain HTTP inside the container
12+
# (MESHCHAT_NO_HTTPS=1) avoids a self-signed HTTPS backend. Do not add host
13+
# ports unless you intend to bypass the proxy.
14+
15+
services:
16+
meshchatx:
17+
image: ${MESHCHAT_IMAGE:-quad4io/meshchatx:latest}
18+
restart: unless-stopped
19+
init: true
20+
user: "1000:1000"
21+
security_opt:
22+
- no-new-privileges:true
23+
cap_drop:
24+
- ALL
25+
read_only: true
26+
tmpfs:
27+
- /tmp:noexec,nosuid,size=256m
28+
- /home/meshchat:nosuid,size=64m
29+
environment:
30+
- SERVICE_URL_MESHCHATX_8000
31+
- MESHCHAT_DEMO_MODE=${MESHCHAT_DEMO_MODE:-1}
32+
- MESHCHAT_AUTH=${MESHCHAT_AUTH:-1}
33+
- MESHCHAT_AUTH_BYPASS=${MESHCHAT_AUTH_BYPASS:-0}
34+
- MESHCHAT_ALTCHA_ENABLED=${MESHCHAT_ALTCHA_ENABLED:-1}
35+
- MESHCHAT_ALTCHA_HMAC_KEY=${MESHCHAT_ALTCHA_HMAC_KEY:?}
36+
- MESHCHAT_DEMO_AUTH_PASSWORD=${MESHCHAT_DEMO_AUTH_PASSWORD:-demo}
37+
- "MESHCHAT_AUTH_PAGE_HINT=${MESHCHAT_AUTH_PAGE_HINT:-Username: demo, Password: demo}"
38+
- MESHCHAT_DISABLE_PLUGINS=${MESHCHAT_DISABLE_PLUGINS:-1}
39+
- MESHCHAT_HOST=${MESHCHAT_HOST:-0.0.0.0}
40+
- MESHCHAT_PORT=${MESHCHAT_PORT:-8000}
41+
- MESHCHAT_NO_HTTPS=${MESHCHAT_NO_HTTPS:-1}
42+
- MESHCHAT_HEADLESS=${MESHCHAT_HEADLESS:-1}
43+
volumes:
44+
- meshchatx-config:/config
45+
healthcheck:
46+
test:
47+
[
48+
"CMD",
49+
"python",
50+
"-c",
51+
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/status')",
52+
]
53+
interval: 30s
54+
timeout: 5s
55+
retries: 3
56+
start_period: 90s
57+
cpus: 2.0
58+
mem_limit: 1g
59+
mem_reservation: 256m
60+
pids_limit: 512
61+
62+
volumes:
63+
meshchatx-config:

docs/en/identity-and-security.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ Access attempts are logged. Repeated failures can trigger lockout when auth is e
3636

3737
Reset a forgotten password with `--reset-password` or `MESHCHAT_RESET_PASSWORD=true`, then set a new password in the UI.
3838

39+
### Demo mode and ALTCHA
40+
41+
`MESHCHAT_DEMO_MODE=1` (or `--demo`) enables a public showcase profile: privacy mode on, plugins off, no outbound announces, and a default-deny HTTP mutation policy with mesh send blocked. Status reports `demo_mode: true`.
42+
43+
When `MESHCHAT_ALTCHA_ENABLED=1`, login and setup require a valid [ALTCHA](https://altcha.org/docs/v2/widget-v3/) proof-of-work payload (widget v3, server challenges use `PBKDF2/SHA-256` by default). Set `MESHCHAT_ALTCHA_HMAC_KEY` to a long random secret on the server. Optional `MESHCHAT_ALTCHA_COST` tunes PoW difficulty. The widget loads from the bundled `altcha` npm package and fetches challenges from `/api/v1/auth/altcha/challenge`.
44+
45+
`MESHCHAT_AUTH_PAGE_HINT` sets optional plain text on the login page (independent of demo mode). Demo Docker compose defaults to username and password hints for the showcase account.
46+
47+
`MESHCHAT_AUTH_BYPASS=1` skips session auth for local testing only. Do not use it on internet-facing deployments.
48+
3949
## Transport security
4050

4151
- HTTPS and WSS are on by default.

docs/en/installation.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ Default Compose maps `127.0.0.1:8000` on the host to port `8000` in the containe
6161

6262
To bind a host directory instead, mount it at `/config`. The container runs as UID 1000. The host directory must be writable by that user.
6363

64+
### Public demo instance (Coolify)
65+
66+
For a read-only mesh showcase on [Coolify](https://coolify.io/docs/knowledge-base/docker/compose), deploy [`docker-compose.demo.yml`](../../docker-compose.demo.yml). For a normal (non-demo) Coolify deployment, use [`docker-compose.coolify.yml`](../../docker-compose.coolify.yml).
67+
68+
- `MESHCHAT_DEMO_MODE=1` blocks outbound mesh actions and almost all API mutations.
69+
- `MESHCHAT_AUTH=1` with default showcase password `demo` (`MESHCHAT_DEMO_AUTH_PASSWORD`).
70+
- Optional `MESHCHAT_AUTH_PAGE_HINT` shows custom text on the login page (for example `Username: demo` and `Password: demo`). Demo compose sets a default hint.
71+
- `MESHCHAT_ALTCHA_ENABLED=1` and a strong `MESHCHAT_ALTCHA_HMAC_KEY` (required in demo compose via `:?`). The UI uses ALTCHA widget v3 with `PBKDF2/SHA-256` challenges from `/api/v1/auth/altcha/challenge`.
72+
- Assign a domain with container port **8000**, for example `https://meshchatx.example.com:8000`.
73+
- Do not set `MESHCHAT_AUTH_BYPASS=1` on a public host.
74+
6475
## Python wheel
6576

6677
1. Download `reticulum_meshchatx-*-py3-none-any.whl` from [releases](https://github.com/Quad4-Software/MeshChatX/releases).

meshchatx.rsm

1.21 KB
Binary file not shown.

meshchatx/meshchat.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@
8181
rotate_session_csrf_token,
8282
validate_csrf_header,
8383
)
84+
from meshchatx.src.backend.altcha_auth import altcha_enabled_from_env
85+
from meshchatx.src.backend.auth_page_hint import auth_page_hint_from_env
86+
from meshchatx.src.backend.demo_mode import (
87+
auth_bypass_from_env,
88+
demo_auth_password_from_env,
89+
)
8490
from meshchatx.src.backend.database.access_attempts import (
8591
LOGIN_PATH,
8692
MAX_FAILED_BEFORE_LOCKOUT,
@@ -560,9 +566,14 @@ def __init__(
560566
plugins_enabled: bool = True,
561567
defer_network_setup: bool = False,
562568
headless: bool = False,
569+
demo_mode: bool = False,
570+
altcha_enabled: bool = False,
563571
):
564572
self.running = True
565573
self.plugins_enabled = plugins_enabled
574+
self.demo_mode = bool(demo_mode)
575+
self.altcha_enabled = bool(altcha_enabled)
576+
self.auth_page_hint = auth_page_hint_from_env()
566577
self._memory_diag_enabled = memory_diag_enabled
567578
self._mem_diag = None
568579
self._headless = bool(headless)
@@ -1034,6 +1045,8 @@ def local_lxmf_destination(self, value):
10341045

10351046
@property
10361047
def auth_enabled(self):
1048+
if auth_bypass_from_env():
1049+
return False
10371050
if self.config:
10381051
return self.config.auth_enabled.get()
10391052
return self.auth_enabled_initial
@@ -1682,6 +1695,11 @@ def telemetry_job():
16821695
self._sideband_telemetry_running = False
16831696

16841697
def _startup_status_payload(self) -> dict:
1698+
demo_fields = {
1699+
"demo_mode": self.demo_mode,
1700+
"altcha_enabled": self.altcha_enabled,
1701+
"auth_page_hint": self.auth_page_hint,
1702+
}
16851703
if self._startup_stage == "failed" or self._startup_error:
16861704
payload = {
16871705
"status": "failed",
@@ -1694,6 +1712,7 @@ def _startup_status_payload(self) -> dict:
16941712
"https_enabled": self.use_https,
16951713
"is_loopback_bind": _is_loopback_bind_host(self.listen_host),
16961714
"plugins_enabled": self.plugins_enabled,
1715+
**demo_fields,
16971716
**self._landlock_status_dict(),
16981717
}
16991718
if self._startup_error:
@@ -1716,6 +1735,7 @@ def _startup_status_payload(self) -> dict:
17161735
"https_enabled": self.use_https,
17171736
"is_loopback_bind": _is_loopback_bind_host(self.listen_host),
17181737
"plugins_enabled": self.plugins_enabled,
1738+
**demo_fields,
17191739
**self._landlock_status_dict(),
17201740
}
17211741

@@ -1831,6 +1851,7 @@ def setup_identity(self, identity: RNS.Identity):
18311851
)
18321852
if self._network_ready:
18331853
self._finish_deferred_startup_services()
1854+
self._apply_demo_mode_runtime()
18341855
return
18351856

18361857
# Initialize Reticulum if not already done
@@ -1881,6 +1902,27 @@ def setup_identity(self, identity: RNS.Identity):
18811902
if self._network_ready:
18821903
self._finish_deferred_startup_services()
18831904

1905+
self._apply_demo_mode_runtime()
1906+
1907+
def _apply_demo_mode_runtime(self) -> None:
1908+
if not self.demo_mode:
1909+
return
1910+
ctx = self.current_context
1911+
if not ctx or not ctx.config:
1912+
return
1913+
self.plugins_enabled = False
1914+
ctx.config.privacy_mode_enabled.set(True)
1915+
ctx.config.auto_announce_enabled.set(False)
1916+
if self.auth_enabled_initial:
1917+
ctx.config.auth_enabled.set(True)
1918+
if self.auth_enabled and ctx.config.auth_password_hash.get() is None:
1919+
password = demo_auth_password_from_env()
1920+
password_hash = bcrypt.hashpw(
1921+
password.encode("utf-8"),
1922+
bcrypt.gensalt(),
1923+
).decode("utf-8")
1924+
ctx.config.auth_password_hash.set(password_hash)
1925+
18841926
def _finish_deferred_startup_services(self) -> None:
18851927
"""Start non-critical services after network_ready is published."""
18861928
context = self.current_context
@@ -5106,6 +5148,7 @@ def _define_routes(self, routes):
51065148
security_middleware,
51075149
csrf_middleware,
51085150
ip_allowlist_middleware,
5151+
demo_mode_middleware,
51095152
) = register_all_routes(routes, self)
51105153

51115154
return (
@@ -5114,6 +5157,7 @@ def _define_routes(self, routes):
51145157
security_middleware,
51155158
csrf_middleware,
51165159
ip_allowlist_middleware,
5160+
demo_mode_middleware,
51175161
)
51185162

51195163
def _encrypted_cookie_storage(self, use_https: bool) -> EncryptedCookieStorage:
@@ -5215,6 +5259,7 @@ def run(self, host, port, launch_browser: bool, enable_https: bool = True):
52155259
security_middleware,
52165260
csrf_middleware,
52175261
ip_allowlist_middleware,
5262+
demo_mode_middleware,
52185263
) = self._define_routes(routes)
52195264

52205265
ssl_context = None
@@ -5324,6 +5369,7 @@ async def on_startup(app):
53245369
security_middleware,
53255370
csrf_middleware,
53265371
ip_allowlist_middleware,
5372+
demo_mode_middleware,
53275373
],
53285374
)
53295375

@@ -5521,6 +5567,8 @@ async def telemetry_tracking_loop(self, session_id, context=None):
55215567

55225568
# handle announcing
55235569
async def announce(self, context=None):
5570+
if self.demo_mode:
5571+
return
55245572
ctx = context or self.current_context
55255573
if not ctx:
55265574
return
@@ -10386,6 +10434,12 @@ def main():
1038610434
default=env_bool("MESHCHAT_AUTH", False),
1038710435
help="Enable basic authentication for the web interface. Can also be set via MESHCHAT_AUTH environment variable.",
1038810436
)
10437+
parser.add_argument(
10438+
"--demo",
10439+
action="store_true",
10440+
default=env_bool("MESHCHAT_DEMO_MODE", False),
10441+
help="Public demo mode: read-only mesh and blocked API mutations. Can also be set via MESHCHAT_DEMO_MODE environment variable.",
10442+
)
1038910443
parser.add_argument(
1039010444
"--no-https",
1039110445
action="store_true",
@@ -10648,6 +10702,16 @@ def main():
1064810702
or args.restore_db
1064910703
or args.restore_from_snapshot,
1065010704
)
10705+
if auth_bypass_from_env():
10706+
print(
10707+
"WARNING: MESHCHAT_AUTH_BYPASS=1 disables web UI authentication",
10708+
file=sys.stderr,
10709+
flush=True,
10710+
)
10711+
10712+
demo_mode = bool(args.demo)
10713+
altcha_on = altcha_enabled_from_env()
10714+
1065110715
reticulum_meshchat = ReticulumMeshChat(
1065210716
identity,
1065310717
args.storage_dir,
@@ -10663,9 +10727,11 @@ def main():
1066310727
rns_loglevel=rns_log_cli,
1066410728
migration_context=migration_context,
1066510729
memory_diag_enabled=args.memory_diag,
10666-
plugins_enabled=not args.disable_plugins,
10730+
plugins_enabled=(not args.disable_plugins) and not demo_mode,
1066710731
defer_network_setup=not needs_immediate_network,
1066810732
headless=bool(args.headless),
10733+
demo_mode=demo_mode,
10734+
altcha_enabled=altcha_on,
1066910735
)
1067010736

1067110737
# store recovery on app for wiring with identity context
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# SPDX-License-Identifier: 0BSD
2+
3+
"""ALTCHA proof-of-work verification for login and setup."""
4+
5+
from __future__ import annotations
6+
7+
import os
8+
import time
9+
from typing import Any
10+
11+
import altcha
12+
from aiohttp import web
13+
14+
from meshchatx.src.env_utils import env_bool
15+
16+
ALTCHA_INVALID_CODE = "altcha_invalid"
17+
ALTCHA_ALGORITHM = "PBKDF2/SHA-256"
18+
ALTCHA_DEFAULT_COST = 2000
19+
20+
21+
def altcha_enabled_from_env() -> bool:
22+
return env_bool("MESHCHAT_ALTCHA_ENABLED", False)
23+
24+
25+
def altcha_hmac_secret() -> str | None:
26+
raw = os.environ.get("MESHCHAT_ALTCHA_HMAC_KEY", "").strip()
27+
return raw or None
28+
29+
30+
def altcha_configured() -> bool:
31+
return altcha_enabled_from_env() and bool(altcha_hmac_secret())
32+
33+
34+
def altcha_pow_cost() -> int:
35+
raw = os.environ.get("MESHCHAT_ALTCHA_COST", "").strip()
36+
if not raw:
37+
return ALTCHA_DEFAULT_COST
38+
try:
39+
return max(100, int(raw))
40+
except ValueError:
41+
return ALTCHA_DEFAULT_COST
42+
43+
44+
def create_altcha_challenge_dict() -> dict[str, Any]:
45+
secret = altcha_hmac_secret()
46+
if not secret:
47+
msg = "MESHCHAT_ALTCHA_HMAC_KEY is required when ALTCHA is enabled"
48+
raise RuntimeError(msg)
49+
expires_at = int(time.time()) + 300
50+
challenge = altcha.create_challenge(
51+
ALTCHA_ALGORITHM,
52+
altcha_pow_cost(),
53+
hmac_secret=secret,
54+
expires_at=expires_at,
55+
)
56+
return challenge.to_dict()
57+
58+
59+
def verify_altcha_submission(payload: Any) -> tuple[bool, str | None]:
60+
secret = altcha_hmac_secret()
61+
if not secret:
62+
return False, "altcha_not_configured"
63+
if payload is None:
64+
return False, ALTCHA_INVALID_CODE
65+
if isinstance(payload, dict):
66+
import json
67+
68+
payload = json.dumps(payload)
69+
if not isinstance(payload, str) or not payload.strip():
70+
return False, ALTCHA_INVALID_CODE
71+
try:
72+
result = altcha.verify_solution(payload.strip(), secret)
73+
except Exception:
74+
return False, ALTCHA_INVALID_CODE
75+
if not result.verified:
76+
err = result.error or ALTCHA_INVALID_CODE
77+
return False, err
78+
return True, None
79+
80+
81+
def altcha_error_response(code: str) -> web.Response:
82+
return web.json_response(
83+
{"error": "ALTCHA verification failed", "code": code},
84+
status=400,
85+
)
86+
87+
88+
async def require_altcha_payload(request, data: dict) -> web.Response | None:
89+
if not altcha_enabled_from_env():
90+
return None
91+
payload = data.get("altcha")
92+
ok, code = verify_altcha_submission(payload)
93+
if not ok:
94+
return altcha_error_response(code or ALTCHA_INVALID_CODE)
95+
return None
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# SPDX-License-Identifier: 0BSD
2+
3+
"""Optional login page hint text from environment."""
4+
5+
from __future__ import annotations
6+
7+
import os
8+
9+
10+
def auth_page_hint_from_env() -> str | None:
11+
raw = os.environ.get("MESHCHAT_AUTH_PAGE_HINT", "").strip()
12+
return raw or None

0 commit comments

Comments
 (0)