331 lines
14 KiB
Python
331 lines
14 KiB
Python
"""Guided enrollment, entirely local and outside the MCP tool surface."""
|
|
|
|
import asyncio
|
|
import getpass
|
|
import warnings
|
|
from dataclasses import replace
|
|
from urllib.parse import quote
|
|
|
|
from . import setup_helpers as ui
|
|
from . import wallet_protection
|
|
from .types import ConnectionProfile, ErrorCode, SafeError
|
|
from .wallets import LABELS
|
|
|
|
|
|
def account_help():
|
|
print(
|
|
"\nRecommended: give Hermes its own Nextcloud account.\n"
|
|
"1. An administrator opens the avatar menu > Accounts (Users on older versions)\n"
|
|
" > New account. Create a user such as hermes with a strong password.\n"
|
|
" Do not add it to the admin group. If you cannot create users, ask your administrator.\n"
|
|
"2. Sign in as that user. Open avatar > Personal settings > Security.\n"
|
|
" Strongly recommended: enable two-factor authentication and save backup codes safely.\n"
|
|
" If no second factor is offered, ask the administrator to enable a 2FA provider.\n"
|
|
"3. Sign in to your main account and open Calendar. Next to each wanted calendar,\n"
|
|
" open its sharing menu (share icon or ... > Edit/Sharing, depending on version).\n"
|
|
" Share with the hermes login name and enable editing for create/edit access.\n"
|
|
" Share only the calendars you want Hermes to access; use a user share, not a public link.\n"
|
|
"4. Sign back in as hermes. Open Personal settings > Security > Devices & sessions.\n"
|
|
" Name a new app password 'Hermes calendar' and click Create new app password.\n"
|
|
" Copy the shown login and app password into this wizard. The password is shown once;\n"
|
|
" if you lose it, revoke it and create another. You can revoke it here at any time.\n"
|
|
"2FA protects browser login. The app password works without a second factor, so protect it.\n"
|
|
"Nextcloud editing permission also allows deletion, though this MCP has no delete tool.\n"
|
|
"Help: https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html\n"
|
|
" https://docs.nextcloud.com/server/latest/user_manual/en/user_2fa.html\n"
|
|
" https://docs.nextcloud.com/server/latest/user_manual/en/groupware/calendar.html\n"
|
|
" https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_configuration.html\n"
|
|
)
|
|
|
|
|
|
def _address(default=None):
|
|
while True:
|
|
suffix = f" [{ui.display(default)}]" if default else ""
|
|
value = input(
|
|
f"Nextcloud web address{suffix} (? for account help; q to cancel): "
|
|
).strip()
|
|
if value == "?":
|
|
account_help()
|
|
continue
|
|
if value.casefold() == "q":
|
|
raise KeyboardInterrupt
|
|
try:
|
|
return ui.normalize_address(value or default or "")
|
|
except SafeError:
|
|
print(
|
|
"Paste your HTTPS Nextcloud site address, including its installation folder if any.\n"
|
|
"A Calendar page address also works. Do not use a public share, DAV resource,\n"
|
|
"password in the address, or an address with query parameters. Example: cloud.example.test/nextcloud"
|
|
)
|
|
|
|
|
|
def _username(default=None):
|
|
while True:
|
|
suffix = f" [{ui.display(default)}]" if default else ""
|
|
value = input(
|
|
f"Nextcloud login name{suffix} (? for help; q to cancel): "
|
|
).strip()
|
|
if value == "?":
|
|
print(
|
|
"Use the login name shown with the newly generated app password, not the account's display name."
|
|
)
|
|
continue
|
|
if value.casefold() == "q":
|
|
raise KeyboardInterrupt
|
|
value = value or default or ""
|
|
if (
|
|
value
|
|
and len(value) <= 8192
|
|
and ":" not in value
|
|
and not any(ord(c) < 32 or ord(c) == 127 for c in value)
|
|
):
|
|
return value
|
|
print("Enter a login name without control characters or a colon.")
|
|
|
|
|
|
def _password():
|
|
print(
|
|
"In this Nextcloud account: Personal settings > Security > Devices & sessions >\n"
|
|
"Create new app password. Use the app password, not your browser login password.\n"
|
|
"Paste it below; nothing will appear while you type. Ctrl+C cancels."
|
|
)
|
|
while True:
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error", getpass.GetPassWarning)
|
|
try:
|
|
password = getpass.getpass("Nextcloud app password (hidden): ")
|
|
except getpass.GetPassWarning:
|
|
print(
|
|
"A hidden password prompt is unavailable. Run setup in a normal interactive terminal."
|
|
)
|
|
raise SafeError(ErrorCode.INVALID_INPUT) from None
|
|
if (
|
|
password
|
|
and len(password) <= 8192
|
|
and not any(ord(c) < 32 or ord(c) == 127 for c in password)
|
|
):
|
|
return password
|
|
print(
|
|
"Paste a nonempty app password without line breaks, or press Ctrl+C to cancel."
|
|
)
|
|
|
|
|
|
def _account(zone, ca, previous=None, password_only=False):
|
|
url = (
|
|
previous.base_url
|
|
if password_only
|
|
else _address(previous.base_url if previous else None)
|
|
)
|
|
user = (
|
|
previous.username
|
|
if password_only
|
|
else _username(previous.username if previous else None)
|
|
)
|
|
password = _password()
|
|
# normalization has already validated the URL and preserved the subpath.
|
|
from .transport import endpoint
|
|
|
|
origin, base, _ = endpoint(url)
|
|
home = base.rstrip("/") + "/remote.php/dav/calendars/" + quote(user, safe="") + "/"
|
|
return ConnectionProfile(
|
|
origin + base.rstrip("/"), user, password, home, (), zone, ca
|
|
)
|
|
|
|
|
|
def _discover(profile, discover):
|
|
while True:
|
|
print(
|
|
"Checking the connection and finding calendars (no events will be read or changed)..."
|
|
)
|
|
try:
|
|
calendars = asyncio.run(discover(profile))
|
|
if calendars:
|
|
return profile, calendars
|
|
print(
|
|
"No calendars are available. Share a calendar with this account, then check again."
|
|
)
|
|
except (SafeError, TimeoutError) as caught:
|
|
error = (
|
|
caught
|
|
if isinstance(caught, SafeError)
|
|
else SafeError(ErrorCode.NETWORK_ERROR)
|
|
)
|
|
print(f"{error.code.value}: {error}")
|
|
if error.code == ErrorCode.AUTH_FAILED:
|
|
print(
|
|
"Check the login name shown with your app password. Use a new app password for this account.\n"
|
|
"Nextcloud may delay repeated failed logins; wait before retrying."
|
|
)
|
|
elif error.code in {
|
|
ErrorCode.NETWORK_ERROR,
|
|
ErrorCode.BAD_RESPONSE,
|
|
ErrorCode.NOT_FOUND,
|
|
}:
|
|
print(
|
|
"Check the direct HTTPS site address and its installation folder. Open it in a browser\n"
|
|
"to check its certificate. Redirects and invalid certificates are not accepted.\n"
|
|
"For a private certificate authority, choose the certificate-bundle option below."
|
|
)
|
|
elif error.code == ErrorCode.PERMISSION_DENIED:
|
|
print(
|
|
"Share the wanted calendars with this Nextcloud account and grant the required access."
|
|
)
|
|
action = ui.menu(
|
|
"Connection recovery",
|
|
[
|
|
"Retry after fixing settings in Nextcloud",
|
|
"Change account/address or app password",
|
|
"Cancel setup",
|
|
"Use a private certificate-authority bundle",
|
|
],
|
|
)
|
|
if action == 3:
|
|
raise SafeError(ErrorCode.INVALID_INPUT)
|
|
if action == 2:
|
|
profile = _account(profile.default_timezone, profile.ca_bundle, profile)
|
|
elif action == 4:
|
|
profile = replace(profile, ca_bundle=_ca_prompt())
|
|
|
|
|
|
def _ca_prompt():
|
|
while True:
|
|
value = input(
|
|
"Path to your trusted CA certificate bundle (Enter for system certificates; q to cancel): "
|
|
).strip()
|
|
if value.casefold() == "q":
|
|
raise KeyboardInterrupt
|
|
try:
|
|
return ui.validate_ca(value)
|
|
except SafeError:
|
|
print(
|
|
"That file is missing or is not a valid certificate bundle. Ask your server administrator for the CA certificates."
|
|
)
|
|
|
|
|
|
def prepare_profile(
|
|
name, choice, store, discover, *, timezone=None, ca_bundle=None, hermes_target=None
|
|
):
|
|
print(
|
|
"\nNextcloud calendar setup\n"
|
|
"Credentials stay in your selected desktop wallet. Returned calendar details can enter\n"
|
|
"Hermes conversations and reach its model provider; check Hermes's privacy settings.\n"
|
|
"Use a dedicated non-admin Nextcloud account with 2FA, and share only wanted calendars.\n"
|
|
"Type ? at the address prompt for step-by-step account, sharing and app-password help.\n"
|
|
)
|
|
verified = wallet_protection.ensure_storage(choice)
|
|
try:
|
|
previous = store.load(name)
|
|
except SafeError as error:
|
|
if error.code != ErrorCode.CREDENTIALS_MISSING:
|
|
raise
|
|
previous = None
|
|
zone = (
|
|
timezone
|
|
or (previous.default_timezone if previous else None)
|
|
or ui.detect_timezone()
|
|
)
|
|
if zone is not None and not ui.valid_timezone(zone):
|
|
raise SafeError(ErrorCode.INVALID_INPUT)
|
|
if zone:
|
|
print(
|
|
f"Detected/default timezone: {ui.display(zone.replace('_', ' '))}. You can change it on the review screen."
|
|
)
|
|
else:
|
|
print(
|
|
"Your computer's timezone could not be identified. Find yours by city name."
|
|
)
|
|
zone = ui.choose_timezone()
|
|
ca = ui.validate_ca(ca_bundle)
|
|
if previous:
|
|
print(
|
|
f"Existing account: {ui.display(previous.username)} at {ui.display(previous.base_url)}"
|
|
)
|
|
action = ui.menu(
|
|
"Account settings",
|
|
[
|
|
"Use saved account and app password",
|
|
"Replace app password",
|
|
"Change account/address",
|
|
],
|
|
)
|
|
if action == 1:
|
|
profile = replace(
|
|
previous,
|
|
default_timezone=timezone or previous.default_timezone,
|
|
ca_bundle=ca if ca_bundle is not None else previous.ca_bundle,
|
|
)
|
|
else:
|
|
profile = _account(
|
|
timezone or previous.default_timezone,
|
|
ca if ca_bundle is not None else previous.ca_bundle,
|
|
previous,
|
|
password_only=action == 2,
|
|
)
|
|
else:
|
|
print(
|
|
"Nextcloud address: paste the address you use in your browser.\n"
|
|
"Need an account? Avatar > Accounts/Users > New account (administrator only).\n"
|
|
"Enable 2FA in that account's Personal settings > Security. From your main account,\n"
|
|
"open Calendar > the calendar's sharing menu and share with that login, with editing enabled."
|
|
)
|
|
profile = _account(zone, ca)
|
|
profile, calendars = _discover(profile, discover)
|
|
chosen = ui.choose_calendars(
|
|
calendars, tuple(c.id for c in previous.calendars) if previous else ()
|
|
)
|
|
while True:
|
|
print(
|
|
f"\nReview\n Account: {ui.display(profile.username)} at {ui.display(profile.base_url)}\n"
|
|
f" Wallet: {LABELS[choice.provider]}\n Timezone: {ui.display(profile.default_timezone)}\n"
|
|
f" Calendars: {', '.join(ui.display(c.name) for c in chosen)}\n"
|
|
f" Certificates: {'custom trusted bundle' if profile.ca_bundle else 'system certificates'}\n"
|
|
f" Storage: {'protected format detected or wallet password set by you' if verified else 'protection override selected; storage may be unprotected'}"
|
|
)
|
|
print(
|
|
f" Hermes configuration: {ui.display(hermes_target.path)}"
|
|
if hermes_target
|
|
else " Hermes: manual configuration block will be printed"
|
|
)
|
|
action = ui.menu(
|
|
"Finish setup",
|
|
[
|
|
"Save and connect to Hermes"
|
|
if hermes_target
|
|
else "Save and show Hermes configuration",
|
|
"Change settings",
|
|
"Cancel without saving",
|
|
],
|
|
)
|
|
if action == 3:
|
|
raise SafeError(ErrorCode.INVALID_INPUT)
|
|
if action == 1:
|
|
if (
|
|
verified
|
|
and wallet_protection.inspect_storage(choice).state == "unprotected"
|
|
):
|
|
# A known downgrade since the earlier check requires fresh consent.
|
|
verified = wallet_protection.ensure_storage(choice)
|
|
return replace(profile, calendars=chosen), verified
|
|
change = ui.menu(
|
|
"Change",
|
|
[
|
|
"Timezone (search by city)",
|
|
"Allowed calendars",
|
|
"Account/address or app password",
|
|
"Certificate-authority bundle",
|
|
"Back to review",
|
|
],
|
|
)
|
|
if change == 1:
|
|
profile = replace(profile, default_timezone=ui.choose_timezone())
|
|
elif change == 2:
|
|
chosen = ui.choose_calendars(calendars, tuple(c.id for c in chosen))
|
|
elif change in {3, 4}:
|
|
profile = (
|
|
_account(profile.default_timezone, profile.ca_bundle, profile)
|
|
if change == 3
|
|
else replace(profile, ca_bundle=_ca_prompt())
|
|
)
|
|
profile, calendars = _discover(profile, discover)
|
|
chosen = ui.choose_calendars(calendars, tuple(c.id for c in chosen))
|