From b8ddf8f22d60747db8041b29db15d497f11f5496 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Tue, 26 May 2026 14:30:21 +0300 Subject: [PATCH] Require LDAP username on first VPN launch --- README.md | 8 +- bin/vpn-lemanapro.sh | 114 ++++++++++++++++-- .../2026-05-24-keychain-totp-provider.md | 2 +- install.sh | 6 +- tests/smoke.sh | 79 +++++++++++- uninstall.sh | 27 ++++- 6 files changed, 210 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index af308bf..5823d5c 100644 --- a/README.md +++ b/README.md @@ -387,7 +387,7 @@ open ~/Applications/LemanaVPN.app # открыть Swift-приложение Файл `~/.config/lemana-vpn/env`: ```sh -LEMANA_VPN_USERNAME="60103293" +LEMANA_VPN_USERNAME="" LEMANA_VPN_CREDENTIAL_SOURCE="bitwarden" LEMANA_VPN_BW_ITEM="LM LDAP" LEMANA_VPN_USE_BITWARDEN="1" @@ -398,18 +398,18 @@ LEMANA_VPN_DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" Для бесплатного Keychain-only источника: ```sh -LEMANA_VPN_USERNAME="60103293" +LEMANA_VPN_USERNAME="" LEMANA_VPN_CREDENTIAL_SOURCE="keychain" LEMANA_VPN_USE_BITWARDEN="0" LEMANA_VPN_USE_TOUCHID="0" LEMANA_VPN_DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" ``` -Для другого логина: +Если LDAP username не указан при установке, `vpn` спросит его при первом интерактивном запуске в Terminal и сохранит в `~/.config/lemana-vpn/env`. Для неинтерактивной установки можно указать его сразу: ```sh curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \ - | sh -s -- --username 12345678 + | sh -s -- --username ``` ## Bitwarden item diff --git a/bin/vpn-lemanapro.sh b/bin/vpn-lemanapro.sh index 85cc636..2b25d88 100755 --- a/bin/vpn-lemanapro.sh +++ b/bin/vpn-lemanapro.sh @@ -3,6 +3,7 @@ set -euo pipefail CONFIG_DIR="${LEMANA_VPN_CONFIG_DIR:-$HOME/.config/lemana-vpn}" CONFIG_FILE="$CONFIG_DIR/env" +OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}" _ENV_LEMANA_VPN_USERNAME="${LEMANA_VPN_USERNAME+x}${LEMANA_VPN_USERNAME-}" _ENV_LEMANA_VPN_BW_ITEM="${LEMANA_VPN_BW_ITEM+x}${LEMANA_VPN_BW_ITEM-}" @@ -27,7 +28,7 @@ OC_VENV="${LEMANA_VPN_OC_VENV:-$HOME/.local/pipx/venvs/openconnect-lite}" OC_PYTHON="${LEMANA_VPN_OC_PYTHON:-$OC_VENV/bin/python}" OC_BIN="${LEMANA_VPN_OC_BIN:-$HOME/.local/bin/openconnect-lite}" BW_ITEM_NAME="${LEMANA_VPN_BW_ITEM:-LM LDAP}" -KC_USERNAME="${LEMANA_VPN_USERNAME:-60103293}" +KC_USERNAME="${LEMANA_VPN_USERNAME:-}" KC_FP="${LEMANA_VPN_KEYCHAIN_FINGERPRINT:-$HOME/bin/keychain-fingerprint}" CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-}" if [[ -z "$CREDENTIAL_SOURCE" ]]; then @@ -648,7 +649,99 @@ PY } _can_prompt() { - [[ -t 0 ]] + [[ -r /dev/tty && -w /dev/tty ]] || [[ -t 0 ]] +} + +_prompt_read() { + local var_name="$1" prompt="$2" secret="${3:-0}" value + if [[ -r /dev/tty && -w /dev/tty ]]; then + if [[ "$secret" == "1" ]]; then + IFS= read -rsp "$prompt" value < /dev/tty || value="" + printf '\n' > /dev/tty + else + IFS= read -rp "$prompt" value < /dev/tty || value="" + fi + else + if [[ "$secret" == "1" ]]; then + IFS= read -rsp "$prompt" value || value="" + printf '\n' + else + IFS= read -rp "$prompt" value || value="" + fi + fi + printf -v "$var_name" '%s' "$value" +} + +_persist_username() { + KC_USERNAME="$KC_USERNAME" CONFIG_FILE="$CONFIG_FILE" OC_CONFIG_FILE="$OC_CONFIG_DIR/config.toml" python3 - <<'PY' +import json +import os +from pathlib import Path + +username = os.environ["KC_USERNAME"] +config_file = Path(os.environ["CONFIG_FILE"]) +oc_config_file = Path(os.environ["OC_CONFIG_FILE"]) + + +def shell_quote(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`") + '"' + + +config_file.parent.mkdir(parents=True, exist_ok=True) +lines = [] +if config_file.exists(): + lines = config_file.read_text().splitlines() + +username_line = "LEMANA_VPN_USERNAME=" + shell_quote(username) +for index, line in enumerate(lines): + if line.startswith("LEMANA_VPN_USERNAME="): + lines[index] = username_line + break +else: + lines.insert(0, username_line) + +config_file.write_text("\n".join(lines).rstrip() + "\n") +config_file.chmod(0o600) + +if oc_config_file.exists(): + oc_lines = oc_config_file.read_text().splitlines() + rendered = "username = " + json.dumps(username) + for index, line in enumerate(oc_lines): + if line.strip().startswith("username"): + oc_lines[index] = rendered + break + else: + oc_lines.insert(0, rendered) + oc_config_file.write_text("\n".join(oc_lines).rstrip() + "\n") + oc_config_file.chmod(0o600) +PY +} + +_ensure_username() { + if [[ -n "${KC_USERNAME:-}" ]]; then + return 0 + fi + + _emit '{"event":"username_required"}' "LDAP username is missing. Enter it once to save it for future VPN runs." + + if ! _can_prompt; then + _emit '{"event":"error","message":"LDAP username is missing. Run vpn in Terminal once, or reinstall with --username ."}' \ + "LDAP username is missing. Run in Terminal: vpn" + return 1 + fi + + while [[ -z "${KC_USERNAME:-}" ]]; do + _prompt_read KC_USERNAME "Corporate LDAP username: " + KC_USERNAME="${KC_USERNAME#"${KC_USERNAME%%[![:space:]]*}"}" + KC_USERNAME="${KC_USERNAME%"${KC_USERNAME##*[![:space:]]}"}" + if [[ -z "$KC_USERNAME" ]]; then + printf 'LDAP username is required.\n' >&2 + fi + done + + export LEMANA_VPN_USERNAME="$KC_USERNAME" + _persist_username + _emit '{"event":"username_saved"}' "LDAP username saved for future VPN runs." } _configure_keychain() { @@ -664,17 +757,15 @@ _configure_keychain() { printf 'Saved values go to macOS Keychain service openconnect-lite.\n\n' if $password_present; then - read -rsp "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " password + _prompt_read password "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " 1 else - read -rsp "Corporate LDAP password for $KC_USERNAME: " password + _prompt_read password "Corporate LDAP password for $KC_USERNAME: " 1 fi - printf '\n' if $totp_present; then - read -rsp "TOTP seed BASE32 [leave empty to keep saved seed]: " totp_secret + _prompt_read totp_secret "TOTP seed BASE32 [leave empty to keep saved seed]: " 1 else - read -rsp "TOTP seed BASE32 from 2FA setup: " totp_secret + _prompt_read totp_secret "TOTP seed BASE32 from 2FA setup: " 1 fi - printf '\n' if [[ -z "$password" && "$password_present" != "true" ]]; then printf 'LDAP password is required because no saved password was found.\n' >&2 @@ -769,8 +860,7 @@ _bw_unlock() { fi _emit '{"event":"bw_manual"}' "Bitwarden vault is locked. Enter Bitwarden master password to sync LDAP credentials." - read -rsp "Bitwarden master password (not LDAP password): " manual_pw - printf '\n' + _prompt_read manual_pw "Bitwarden master password (not LDAP password): " 1 if [[ -z "$manual_pw" ]]; then printf 'Empty Bitwarden password. Using existing Keychain credentials.\n' >&2 return 1 @@ -786,7 +876,7 @@ _bw_unlock() { if [[ "$USE_TOUCHID" == "1" && -x "$KC_FP" ]]; then local save_choice - read -rp "Save Bitwarden master password behind Touch ID for next VPN unlock? [Y/n] " save_choice + _prompt_read save_choice "Save Bitwarden master password behind Touch ID for next VPN unlock? [Y/n] " if [[ "${save_choice:-y}" =~ ^[Yy]?$ ]]; then printf '%s' "$manual_pw" | "$KC_FP" set "$BW_KC_SERVICE" "$BW_KC_ACCOUNT_MASTER" >/dev/null 2>&1 \ && printf 'Saved. Next unlock can use Touch ID.\n' \ @@ -921,6 +1011,7 @@ if $STATUS_MODE; then fi if $CONFIGURE_KEYCHAIN_MODE; then + _ensure_username _configure_keychain exit 0 fi @@ -936,6 +1027,7 @@ else printf '{"event":"modules","modules":%s}\n' "$(_module_status_json)" fi +_ensure_username _sync_credentials _ensure_keychain_credentials _patch_oc diff --git a/docs/superpowers/plans/2026-05-24-keychain-totp-provider.md b/docs/superpowers/plans/2026-05-24-keychain-totp-provider.md index 6271366..c697278 100644 --- a/docs/superpowers/plans/2026-05-24-keychain-totp-provider.md +++ b/docs/superpowers/plans/2026-05-24-keychain-totp-provider.md @@ -772,7 +772,7 @@ LEMANA_VPN_CREDENTIAL_SOURCE="bitwarden" For the keychain source, add this separate example: ```sh -LEMANA_VPN_USERNAME="60103293" +LEMANA_VPN_USERNAME="" LEMANA_VPN_CREDENTIAL_SOURCE="keychain" LEMANA_VPN_USE_BITWARDEN="0" LEMANA_VPN_USE_TOUCHID="0" diff --git a/install.sh b/install.sh index e49caf9..ed0390c 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ INSTALL_BIN_DIR="${LEMANA_VPN_BIN_DIR:-$HOME/bin}" CONFIG_DIR="${LEMANA_VPN_CONFIG_DIR:-$HOME/.config/lemana-vpn}" OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}" DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" -USERNAME="${LEMANA_VPN_USERNAME:-60103293}" +USERNAME="${LEMANA_VPN_USERNAME:-}" BW_ITEM="${LEMANA_VPN_BW_ITEM:-LM LDAP}" CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-bitwarden}" USE_BITWARDEN=1 @@ -81,7 +81,7 @@ Options: --with-touchid Install/use keychain-fingerprint Touch ID helper (default) --without-touchid Do not install/use Touch ID helper --configure-keychain Prompt for LDAP password and TOTP secret after install - --username VALUE Corporate LDAP username (default: 60103293) + --username VALUE Corporate LDAP username (otherwise asked on first Terminal connection) --bw-item VALUE Bitwarden item name (default: LM LDAP) --raw-base-url URL Raw file base URL for curl installs --no-sudoers Do not install sudoers rules @@ -615,7 +615,7 @@ install_cli() { install_config() { tmp="$1" log_step "Записываю конфигурацию" - log_detail "Здесь сохраняются выбранные модули, LDAP username и профиль openconnect-lite для Keycloak SSO." + log_detail "Здесь сохраняются выбранные модули, LDAP username если он задан, и профиль openconnect-lite для Keycloak SSO." run mkdir -p "$CONFIG_DIR" "$OC_CONFIG_DIR" diff --git a/tests/smoke.sh b/tests/smoke.sh index fea9db5..60fec9b 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -61,6 +61,11 @@ printf '%s\n' "$keychain_status_json" | grep -q '"touchid":{"enabled":false' bitwarden_status_json="$(LEMANA_VPN_CREDENTIAL_SOURCE=bitwarden bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)" printf '%s\n' "$bitwarden_status_json" | grep -q '"credentials":{"source":"bitwarden","keychain_ready":false}' printf '%s\n' "$bitwarden_status_json" | grep -q '"bitwarden":{"enabled":true' +personal_ldap='601''03293' +if grep -R "$personal_ldap" "$ROOT/bin" "$ROOT/install.sh" "$ROOT/uninstall.sh" "$ROOT/README.md" "$ROOT/templates" "$ROOT/app" "$ROOT/.agents" >/dev/null; then + echo "personal LDAP username must not be hardcoded in runtime, installer, app, docs, or agent guidance" >&2 + exit 1 +fi grep -q 'LemanaVPN-openconnect-lite.log' "$ROOT/bin/vpn-lemanapro.sh" grep -q '"event":"waiting"' "$ROOT/bin/vpn-lemanapro.sh" grep -q -- '--patch-only' "$ROOT/bin/vpn-lemanapro.sh" @@ -238,12 +243,43 @@ if printf '%s\n' "$manual_output" | grep -q 'Cleaning up VPN DNS'; then exit 1 fi +missing_username_home="$TMP_DIR/missing-username-home" +mkdir -p "$missing_username_home" +set +e +missing_username_output="$( + HOME="$missing_username_home" \ + LEMANA_VPN_CONFIG_DIR="$missing_username_home/.config/lemana-vpn" \ + OPENCONNECT_LITE_CONFIG_DIR="$missing_username_home/.config/openconnect-lite" \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + bash "$ROOT/bin/vpn-lemanapro.sh" --json 2>&1 +)" +missing_username_code=$? +set -e + +[ "$missing_username_code" -ne 0 ] +printf '%s\n' "$missing_username_output" | grep -q '"event":"username_required"' +printf '%s\n' "$missing_username_output" | grep -q 'LDAP username is missing' +if printf '%s\n' "$missing_username_output" | grep -q '"event":"keychain_required"'; then + echo "missing username should fail before checking Keychain credentials" >&2 + exit 1 +fi + fake_path_bin="$TMP_DIR/fake-path-bin" mkdir -p "$fake_path_bin" cat > "$fake_path_bin/security" <<'SH' #!/bin/sh if [ "${1:-}" = "find-generic-password" ]; then - exit 0 + account="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "-a" ]; then + shift + account="${1:-}" + break + fi + shift + done + [ -n "$account" ] + exit $? fi exit 1 SH @@ -262,6 +298,47 @@ exit 0 SH chmod +x "$fake_oc_bin" +if command -v expect >/dev/null 2>&1; then + first_username_home="$TMP_DIR/first-username-home" + mkdir -p "$first_username_home/.config/lemana-vpn" "$first_username_home/.config/openconnect-lite" + printf 'LEMANA_VPN_CREDENTIAL_SOURCE="keychain"\nLEMANA_VPN_USE_BITWARDEN="0"\nLEMANA_VPN_USE_TOUCHID="0"\n' > "$first_username_home/.config/lemana-vpn/env" + printf 'username = ""\n' > "$first_username_home/.config/openconnect-lite/config.toml" + + FIRST_USERNAME_HOME="$first_username_home" \ + ROOT="$ROOT" \ + FAKE_PATH_BIN="$fake_path_bin" \ + FAKE_OC_BIN="$fake_oc_bin" \ + FAKE_WEBENGINE="$fake_webengine" \ + FAKE_AUTHENTICATOR="$fake_authenticator" \ + MANUAL_FULL_CAPTURE="$manual_full_capture" \ + TMP_DIR="$TMP_DIR" \ + expect <<'EXPECT' +set timeout 30 +log_user 0 +set home $env(FIRST_USERNAME_HOME) +spawn env \ + HOME=$home \ + PATH=$env(FAKE_PATH_BIN):$env(PATH) \ + LEMANA_VPN_CONFIG_DIR=$home/.config/lemana-vpn \ + OPENCONNECT_LITE_CONFIG_DIR=$home/.config/openconnect-lite \ + LEMANA_VPN_OC_BIN=$env(FAKE_OC_BIN) \ + LEMANA_VPN_OC_PYTHON=python3 \ + LEMANA_VPN_WEBENGINE_PROCESS=$env(FAKE_WEBENGINE) \ + LEMANA_VPN_AUTHENTICATOR=$env(FAKE_AUTHENTICATOR) \ + LEMANA_VPN_PATCH_BACKUP_DIR=$env(TMP_DIR)/first-username-patch-backups \ + LEMANA_VPN_DNS_CLEANUP=$env(TMP_DIR)/no-dns-cleanup \ + LEMANA_VPN_CONNECT_LOG=$env(TMP_DIR)/first-username.log \ + LEMANA_VPN_CAPTURE_LAUNCH=$env(MANUAL_FULL_CAPTURE) \ + bash $env(ROOT)/bin/vpn-lemanapro.sh --manual-full --json +expect "Corporate LDAP username:" +send "lemana-first-user\r" +expect eof +EXPECT + + grep -q 'LEMANA_VPN_USERNAME="lemana-first-user"' "$first_username_home/.config/lemana-vpn/env" + grep -q 'username = "lemana-first-user"' "$first_username_home/.config/openconnect-lite/config.toml" +fi + manual_full_output="$( HOME="$HOME" \ PATH="$fake_path_bin:$PATH" \ diff --git a/uninstall.sh b/uninstall.sh index 2c93968..fd29a03 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -3,10 +3,17 @@ set -eu INSTALL_BIN_DIR="${LEMANA_VPN_BIN_DIR:-$HOME/bin}" CONFIG_DIR="${LEMANA_VPN_CONFIG_DIR:-$HOME/.config/lemana-vpn}" +CONFIG_FILE="$CONFIG_DIR/env" OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}" OC_VENV="${LEMANA_VPN_OC_VENV:-$HOME/.local/pipx/venvs/openconnect-lite}" DNS_CLEANUP="${LEMANA_VPN_DNS_CLEANUP:-/usr/local/sbin/lemana-vpn-dns-cleanup}" -USERNAME="${LEMANA_VPN_USERNAME:-60103293}" +_ENV_LEMANA_VPN_USERNAME="${LEMANA_VPN_USERNAME+x}${LEMANA_VPN_USERNAME-}" +if [ -f "$CONFIG_FILE" ]; then + # shellcheck disable=SC1090 + . "$CONFIG_FILE" +fi +[ "${_ENV_LEMANA_VPN_USERNAME%"${_ENV_LEMANA_VPN_USERNAME#?}"}" = "x" ] && LEMANA_VPN_USERNAME="${_ENV_LEMANA_VPN_USERNAME#?}" +USERNAME="${LEMANA_VPN_USERNAME:-}" APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}" LAUNCH_AGENT="$HOME/Library/LaunchAgents/ru.dokops.LemanaVPN.plist" DRY_RUN=0 @@ -203,15 +210,23 @@ remove_keychain_entries() { fi log_step "Удаляю VPN-записи из macOS Keychain" - log_detail "Удаляются только записи openconnect-lite для пользователя $USERNAME и кэш Bitwarden Lemana VPN." + if [ -n "$USERNAME" ]; then + log_detail "Удаляются только записи openconnect-lite для пользователя $USERNAME и кэш Bitwarden Lemana VPN." + else + log_detail "LDAP username не задан; удаляется только кэш Bitwarden Lemana VPN." + fi if [ "$DRY_RUN" -eq 1 ]; then - run security delete-generic-password -s openconnect-lite -a "$USERNAME" - run security delete-generic-password -s openconnect-lite -a "totp/$USERNAME" + if [ -n "$USERNAME" ]; then + run security delete-generic-password -s openconnect-lite -a "$USERNAME" + run security delete-generic-password -s openconnect-lite -a "totp/$USERNAME" + fi run security delete-generic-password -s vpn-lemanapro -a bw-session run security delete-generic-password -s vpn-lemanapro -a bw-master else - security delete-generic-password -s openconnect-lite -a "$USERNAME" >/dev/null 2>&1 || true - security delete-generic-password -s openconnect-lite -a "totp/$USERNAME" >/dev/null 2>&1 || true + if [ -n "$USERNAME" ]; then + security delete-generic-password -s openconnect-lite -a "$USERNAME" >/dev/null 2>&1 || true + security delete-generic-password -s openconnect-lite -a "totp/$USERNAME" >/dev/null 2>&1 || true + fi security delete-generic-password -s vpn-lemanapro -a bw-session >/dev/null 2>&1 || true security delete-generic-password -s vpn-lemanapro -a bw-master >/dev/null 2>&1 || true fi