Add the configure app

This commit is contained in:
2026-10-26 15:16:53 -06:00
parent 63d56da089
commit bbd6b83f64
21 changed files with 1010 additions and 1 deletions
+315
View File
@@ -0,0 +1,315 @@
#!/bin/bash
TITLE="Federated Computer Core"
DESCRIPTION="Superpanel, Tailscale, Nextcloud, email and files"
# What you entered last time, so running this again only does what's missing
CORE_CONF="$HOME/.config/coredesktop/core.conf"
GOA_ACCOUNT="$COREDESKTOP/configure.d/lib/goa-account"
[ -f "$CORE_CONF" ] && source "$CORE_CONF"
# CORE_DOMAIN, CORE_EMAIL and CORE_NAME come from core.conf; the rest follow
core_names() {
CORE_USER="${CORE_EMAIL%@*}"
NEXTCLOUD="https://nextcloud.$CORE_DOMAIN"
WEBDAV="$NEXTCLOUD/remote.php/dav/files/$CORE_USER"
MOUNT="/mnt/$CORE_DOMAIN"
SUPERPANEL_DESKTOP="$HOME/.local/share/applications/core-$CORE_DOMAIN-superpanel.desktop"
}
superpanel_done() {
[ -f "$SUPERPANEL_DESKTOP" ]
}
# Tailscale has no status field naming its login server, so core.conf
# records that it was set up for this Core
tailscale_done() {
[ "$CORE_TAILSCALE" = "$CORE_DOMAIN" ] \
&& tailscale status --json 2> /dev/null | jq -e '.BackendState == "Running"' > /dev/null
}
nextcloud_done() {
"$GOA_ACCOUNT" exists owncloud "$CORE_USER@nextcloud.$CORE_DOMAIN" 2> /dev/null
}
email_done() {
"$GOA_ACCOUNT" exists imap_smtp "$CORE_EMAIL" 2> /dev/null
}
files_done() {
grep -qs " $MOUNT davfs " /etc/fstab && grep -qs "^$MOUNT " "$HOME/.davfs2/secrets"
}
is_configured() {
[ -n "$CORE_DOMAIN" ] || return 1
core_names
superpanel_done && tailscale_done && nextcloud_done && email_done && files_done
}
save_conf() {
mkdir -p "$(dirname "$CORE_CONF")"
{
printf 'CORE_DOMAIN=%q\n' "$CORE_DOMAIN"
printf 'CORE_EMAIL=%q\n' "$CORE_EMAIL"
printf 'CORE_NAME=%q\n' "$CORE_NAME"
printf 'CORE_TAILSCALE=%q\n' "$CORE_TAILSCALE"
} > "$CORE_CONF"
}
# Basic auth header for curl, read from stdin (-H @-) so the password isn't
# in the process list
auth_header() {
printf 'Authorization: Basic %s\n' "$(printf '%s:%s' "$1" "$2" | base64 -w0)"
}
# Create a Nextcloud app password for the Online Accounts account and the
# files mount, so they don't keep your Core password. It shows in Nextcloud
# under Settings > Security as "CoreDesktop on <this computer>", where it can
# be revoked. Returns 2 if Nextcloud says the password is wrong.
nextcloud_app_password() {
local response code
response="$(auth_header "$CORE_USER" "$PASSWORD" | curl -s -w '\n%{http_code}' -H @- \
-H 'OCS-APIRequest: true' -A "CoreDesktop on $HOSTNAME" \
"$NEXTCLOUD/ocs/v2.php/core/getapppassword?format=json")"
code="${response##*$'\n'}"
case "$code" in
200) APP_PASSWORD="$(jq -r '.ocs.data.apppassword // empty' <<< "${response%$'\n'*}")"
[ -n "$APP_PASSWORD" ] ;;
401) return 2 ;;
*) return 1 ;;
esac
}
desktop_entry() {
local file="$1" name="$2" comment="$3" url="$4" icon="$5"
mkdir -p "$(dirname "$file")"
cat > "$file" << EOF
[Desktop Entry]
Type=Application
Name=$name
Comment=$comment
Exec=xdg-open $url
Icon=$icon
Categories=Network;
EOF
}
setup_superpanel() {
local url="https://superpanel.$CORE_DOMAIN"
local icon="$HOME/.local/share/icons/core-$CORE_DOMAIN-superpanel.png"
# The login page carries Superpanel's icon inline
mkdir -p "$(dirname "$icon")"
if ! curl -s "$url/" | grep -o 'rel="icon"[^>]*base64,[^"]*' | head -n 1 \
| sed 's/.*base64,//' | base64 -d > "$icon" 2> /dev/null || [ ! -s "$icon" ]; then
rm -f "$icon"
icon="applications-internet"
fi
desktop_entry "$SUPERPANEL_DESKTOP" "Superpanel" "Manage your Federated Computer Core ($CORE_DOMAIN)" "$url" "$icon"
echo "Added Superpanel to your applications."
}
setup_tailscale() {
# --reset and --force-reauth move the machine from any other tailnet;
# --operator lets you, and the Tailscale GNOME extension, use it without sudo
echo "Open the link below to sign in to your Core's Tailscale network."
sudo tailscale up --reset --force-reauth --login-server "https://headscale.$CORE_DOMAIN" \
--accept-routes --operator="$USER" || return 1
CORE_TAILSCALE="$CORE_DOMAIN"
save_conf
}
setup_nextcloud() {
[ -n "$APP_PASSWORD" ] || { echo "This needs your Core password, and Nextcloud didn't accept it."; return 1; }
"$GOA_ACCOUNT" add << EOF || return 1
$(jq -n --arg user "$CORE_USER" --arg host "nextcloud.$CORE_DOMAIN" --arg url "$NEXTCLOUD" \
--arg password "$APP_PASSWORD" '{
provider: "owncloud",
identity: $user,
presentation_identity: "\($user)@\($host)",
credentials: {password: $password},
details: {
Uri: "\($url)/remote.php/webdav",
FilesEnabled: "true",
CalendarEnabled: "true",
CalDavUri: "\($url)/remote.php/dav",
ContactsEnabled: "true",
CardDavUri: "\($url)/remote.php/dav",
AcceptSslErrors: "false"
}}')
EOF
echo "Added Nextcloud to Online Accounts (calendar, contacts and files)."
}
# The mail server publishes its settings (Thunderbird's autoconfig format).
# Prints: imap_host imap_port imap_socket smtp_host smtp_port smtp_socket username
mail_settings() {
curl -sf --max-time 15 \
"https://autoconfig.$CORE_DOMAIN/mail/config-v1.1.xml?emailaddress=$CORE_EMAIL" \
| python3 -I -c '
import sys, xml.etree.ElementTree as ET
provider = ET.parse(sys.stdin).getroot().find("emailProvider")
imap = provider.find("incomingServer[@type=\"imap\"]")
smtp = provider.find("outgoingServer[@type=\"smtp\"]")
print(*(s.findtext(f) for s in (imap, smtp) for f in ("hostname", "port", "socketType")),
imap.findtext("username"))'
}
# Online Accounts takes host:port, and only the host for the usual port
host_port() {
if [ "$2" = "$3" ]; then echo "$1"; else echo "$1:$2"; fi
}
setup_email() {
[ -n "$PASSWORD" ] || { echo "This needs your Core password."; return 1; }
local imap_host imap_port imap_socket smtp_host smtp_port smtp_socket username
if ! read -r imap_host imap_port imap_socket smtp_host smtp_port smtp_socket username < <(mail_settings) \
|| [ -z "$smtp_socket" ]; then
echo "The mail server didn't give its settings; using mail.$CORE_DOMAIN with the usual ports."
imap_host="mail.$CORE_DOMAIN" imap_port=993 imap_socket=SSL
smtp_host="mail.$CORE_DOMAIN" smtp_port=587 smtp_socket=STARTTLS
username="%EMAILADDRESS%"
fi
case "$username" in
%EMAILLOCALPART%) username="$CORE_USER" ;;
*) username="$CORE_EMAIL" ;;
esac
[ "$imap_socket" = SSL ] && imap_std=993 || imap_std=143
[ "$smtp_socket" = SSL ] && smtp_std=465 || smtp_std=587
"$GOA_ACCOUNT" add << EOF || return 1
$(jq -n --arg email "$CORE_EMAIL" --arg name "$CORE_NAME" --arg user "$username" \
--arg password "$PASSWORD" \
--arg imap "$(host_port "$imap_host" "$imap_port" "$imap_std")" --arg imap_socket "$imap_socket" \
--arg smtp "$(host_port "$smtp_host" "$smtp_port" "$smtp_std")" --arg smtp_socket "$smtp_socket" '{
provider: "imap_smtp",
identity: $email,
presentation_identity: $email,
credentials: {"imap-password": $password, "smtp-password": $password},
details: {
Enabled: "true",
EmailAddress: $email,
Name: $name,
ImapHost: $imap,
ImapUserName: $user,
ImapUseSsl: (if $imap_socket == "SSL" then "true" else "false" end),
ImapUseTls: (if $imap_socket == "STARTTLS" then "true" else "false" end),
ImapAcceptSslErrors: "false",
SmtpHost: $smtp,
SmtpUseAuth: "true",
SmtpUserName: $user,
SmtpAuthLogin: "false",
SmtpAuthPlain: "true",
SmtpUseSsl: (if $smtp_socket == "SSL" then "true" else "false" end),
SmtpUseTls: (if $smtp_socket == "STARTTLS" then "true" else "false" end),
SmtpAcceptSslErrors: "false"
}}')
EOF
echo "Added $CORE_EMAIL to Online Accounts."
}
# Mount your Nextcloud files at /mnt/<domain> with davfs2. You can mount it
# without sudo once it's in /etc/fstab with "user" and you're in Arch's davfs2
# group (network). It's mounted now and each time you log in.
setup_files() {
[ -n "$APP_PASSWORD" ] || { echo "This needs your Core password, and Nextcloud didn't accept it."; return 1; }
local secret
sudo install -d -o "$USER" -g "$(id -gn)" "$MOUNT" || return 1
if ! grep -qs " $MOUNT davfs " /etc/fstab; then
echo "$WEBDAV $MOUNT davfs rw,user,noauto,uid=$USER,_netdev 0 0" | sudo tee -a /etc/fstab > /dev/null || return 1
fi
if ! id -nG "$USER" | grep -qw network; then
sudo usermod -aG network "$USER" || return 1
fi
# davfs2 only reads this file if no one else can
mkdir -p -m 700 "$HOME/.davfs2"
touch "$HOME/.davfs2/secrets"
chmod 600 "$HOME/.davfs2/secrets"
secret="${APP_PASSWORD//\\/\\\\}"
secret="${secret//\"/\\\"}"
sed -i "\\#^$MOUNT #d" "$HOME/.davfs2/secrets"
printf '%s %s "%s"\n' "$MOUNT" "$CORE_USER" "$secret" >> "$HOME/.davfs2/secrets"
mkdir -p "$HOME/.config/autostart"
cat > "$HOME/.config/autostart/core-$CORE_DOMAIN-files.desktop" << EOF
[Desktop Entry]
Type=Application
Name=Nextcloud files ($CORE_DOMAIN)
Comment=Mount your Nextcloud files at $MOUNT
Exec=sh -c 'mountpoint -q $MOUNT || mount $MOUNT'
X-GNOME-Autostart-enabled=true
NoDisplay=true
EOF
# Shown in the Files sidebar as the domain
local bookmarks="$HOME/.config/gtk-3.0/bookmarks"
mkdir -p "$(dirname "$bookmarks")"
grep -qs "^file://$MOUNT " "$bookmarks" || echo "file://$MOUNT $CORE_DOMAIN" >> "$bookmarks"
# sg picks up the network group now, without logging out and in again
if ! mountpoint -q "$MOUNT"; then
sg network -c "mount $MOUNT" || return 1
fi
echo "Your Nextcloud files are at $MOUNT (\"$CORE_DOMAIN\" in the Files sidebar)."
}
# Run a step unless it's done; a failed step doesn't stop the ones after it
step() {
local name="$1" title="$2"
if "${name}_done"; then
echo " $title: already set up"
return 0
fi
if ! confirm_yes "Set up $title?"; then
return 0
fi
"setup_$name" || { echo -e "\e[31m$title was not set up.\e[0m"; STATUS=1; }
}
run_configure() {
local url
STATUS=0
read -erp "Your Superpanel's address (e.g. https://superpanel.example.com): " \
-i "${CORE_DOMAIN:+https://superpanel.$CORE_DOMAIN}" url
url="${url#*://}"
url="${url%%/*}"
CORE_DOMAIN="${url#superpanel.}"
if [ -z "$CORE_DOMAIN" ] || ! curl -s -o /dev/null --max-time 15 "https://superpanel.$CORE_DOMAIN/"; then
echo "Couldn't reach https://superpanel.$CORE_DOMAIN."
return 1
fi
read -erp "Your Core email: " -i "${CORE_EMAIL:-$USER@$CORE_DOMAIN}" CORE_EMAIL
read -erp "Your name, as shown on email you send: " \
-i "${CORE_NAME:-$(getent passwd "$USER" | cut -d: -f5 | cut -d, -f1)}" CORE_NAME
[[ "$CORE_EMAIL" == *@* ]] || { echo "That isn't an email address."; return 1; }
core_names
save_conf
# Nextcloud, email and files need your Core password; Nextcloud checks it
if ! nextcloud_done || ! email_done || ! files_done; then
local tries
for tries in 1 2 3; do
read -rsp "Your Core password: " PASSWORD
echo
nextcloud_app_password
case $? in
0) break ;;
2) echo "Nextcloud says that password is wrong."; PASSWORD="" ;;
*) echo "Couldn't reach Nextcloud at $NEXTCLOUD; trying the password for email anyway."; break ;;
esac
done
fi
echo
step superpanel "Superpanel in your applications"
step tailscale "Tailscale with your Core (headscale.$CORE_DOMAIN)"
step nextcloud "Nextcloud in Online Accounts"
step email "Email in Online Accounts"
step files "Nextcloud files at $MOUNT"
unset PASSWORD APP_PASSWORD
return $STATUS
}
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
TITLE="Claude Code"
DESCRIPTION="Sign in to Anthropic"
is_installed() {
command -v claude
}
is_configured() {
claude auth status
}
run_configure() {
echo " 1) Claude subscription (Pro, Max, Team or Enterprise)"
echo " 2) Anthropic Console (pay for API usage)"
local choice
read -rp "How do you pay for Claude? [1] " choice
case "$choice" in
""|1) claude auth login ;;
2) claude auth login --console ;;
*) echo "Choose 1 or 2."; return 1 ;;
esac
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
TITLE="opencode"
DESCRIPTION="Sign in to AI providers"
is_installed() {
[ -x /usr/bin/opencode ]
}
# Signed in to at least one provider
is_configured() {
jq -e 'length > 0' "$HOME/.local/share/opencode/auth.json"
}
run_configure() {
echo "Choose a provider and sign in or paste its API key. You can pick the model inside opencode with /models."
unsandboxed opencode auth login || return 1
while confirm "Sign in to another provider?"; do
unsandboxed opencode auth login || return 1
done
}
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
TITLE="Hermes Agent"
DESCRIPTION="Choose a model provider and tools"
is_installed() {
[ -x "$HOME/.local/bin/hermes" ]
}
# Signed in with OAuth (e.g. Nous Portal), or an API key is set
is_configured() {
[ -s "$HOME/.hermes/auth.json" ] || grep -qE '^[A-Z_]*API_KEY=.+' "$HOME/.hermes/.env"
}
run_configure() {
echo " 1) Nous Portal (one sign-in covers the model, web search, images and more)"
echo " 2) Another provider (OpenRouter, OpenAI, Anthropic or your own endpoint)"
local choice
read -rp "Which provider? [1] " choice
case "$choice" in
""|1) unsandboxed hermes setup --portal ;;
2) unsandboxed hermes setup ;;
*) echo "Choose 1 or 2."; return 1 ;;
esac
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Add an account to GNOME Online Accounts, the way Settings does.
Settings adds accounts by calling AddAccount on the Online Accounts service,
which saves the account and keeps its password in the GNOME keyring. This
calls the same method. The account comes on stdin rather than as arguments,
so its password doesn't show up in the process list.
Usage:
goa-account exists <provider> <presentation-identity>
goa-account add < account.json
account.json:
{"provider": "owncloud", "identity": "...", "presentation_identity": "...",
"credentials": {"password": "..."}, "details": {"Uri": "...", ...}}
"""
import json
import sys
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib # noqa: E402
BUS_NAME = "org.gnome.OnlineAccounts"
def call(bus, path, interface, method, parameters, reply_type):
return bus.call_sync(BUS_NAME, path, interface, method, parameters,
GLib.VariantType(reply_type), Gio.DBusCallFlags.NONE,
-1, None).unpack()
def accounts(bus):
(objects,) = call(bus, "/org/gnome/OnlineAccounts",
"org.freedesktop.DBus.ObjectManager", "GetManagedObjects",
None, "(a{oa{sa{sv}}})")
for interfaces in objects.values():
account = interfaces.get("org.gnome.OnlineAccounts.Account")
if account:
yield account
def exists(bus, provider, presentation_identity):
return any(account.get("ProviderType") == provider
and account.get("PresentationIdentity") == presentation_identity
for account in accounts(bus))
def add(bus, account):
credentials = {key: GLib.Variant("s", value)
for key, value in account["credentials"].items()}
parameters = GLib.Variant("(sssa{sv}a{ss})", (
account["provider"], account["identity"],
account["presentation_identity"], credentials, account["details"]))
call(bus, "/org/gnome/OnlineAccounts/Manager",
"org.gnome.OnlineAccounts.Manager", "AddAccount", parameters, "(o)")
def main():
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
if len(sys.argv) == 4 and sys.argv[1] == "exists":
return 0 if exists(bus, sys.argv[2], sys.argv[3]) else 1
if len(sys.argv) == 2 and sys.argv[1] == "add":
account = json.load(sys.stdin)
if exists(bus, account["provider"], account["presentation_identity"]):
return 0
add(bus, account)
return 0
print(__doc__.strip().split("\n\n")[2], file=sys.stderr)
return 2
if __name__ == "__main__":
try:
sys.exit(main())
except GLib.Error as error:
print(f"GNOME Online Accounts: {error.message}", file=sys.stderr)
sys.exit(1)