~/notes / obsidian-livesync-couchdb-mcp

2026-09-19🐧 Linux22 мин0 просм.

Свой сервер синхронизации Obsidian: LiveSync, CouchDB и ИИ через MCP - со всеми граблями

Заметки я держу в Obsidian на ПК и на iPhone, и отдавать их в чужое облако не хотел. Поднял свой сервер: CouchDB за nginx с TLS, плагин Self-hosted LiveSync на устройствах и рядом MCP-сервер, через который ИИ-ассистент читает и пишет те же заметки. Работает, но завелось не с первого раза: большая часть текста ниже про грабли, которых в готовых инструкциях нет.

Проверено на Obsidian 1.13.7 (Windows и iOS 18), плагине Self-hosted LiveSync 1.0.30, CouchDB 3.5.2.1, obsidian-sync-mcp v0.6.2, nginx 1.26, Debian, Docker из репозитория дистрибутива (docker.io), compose v5. Домен в примерах obsidian.example.com, хранилище "Моё хранилище", паролей нигде нет.

Что получается

ПК (Obsidian) ─────┐
iPhone (Obsidian) ─┼─HTTPS─▶ nginx :443 obsidian.example.com ─▶ 127.0.0.1:5984 CouchDB
                   │                                                  ▲
Claude Code ───────┴──────────────────────▶ 127.0.0.1:8787 MCP ────────┘
(на сервере)                                (наружу не публикуется)

CouchDB хранит заметки, которые туда складывает плагин Self-hosted LiveSync, причём в зашифрованном виде. Наружу база смотрит только через nginx с TLS. MCP-сервер obsidian-sync-mcp читает ту же базу, расшифровывает и отдаёт заметки ИИ, слушает он только loopback. На nginx TLS и фильтр служебных адресов CouchDB.

Частый вопрос: а можно создать хранилище прямо на сервере, чтобы оно всегда было доступно? По сути так и есть, сервер хранит полную копию и работает круглосуточно. Но "создать хранилище на сервере" нельзя: Obsidian локальная программа, хранилище для него всегда папка на устройстве, веб-версии нет. Плюс заметки шифрует плагин на устройстве, сервер ключа не использует.

Поэтому кто-то один делает первую загрузку, дальше ПК можно выключать: телефон и ИИ берут всё с сервера. Каждое устройство держит полную копию, так что Obsidian работает и без интернета.

Почему не готовые скрипты в две команды

Я нашёл инструкцию вида wget ... && sudo bash script.sh. Скрипты скачал и прочитал. Запускать их было нельзя.

Первый скрипт, про безопасность сервера, делает ufw --force reset и сносит все правила файрвола, меняет порт SSH и перезапускает sshd, отчего легко потерять доступ, включает PasswordAuthentication yes, перезаписывает /etc/fail2ban/jail.local, так что остальные jail'ы исчезают, и ставит Docker из Ubuntu-репозитория через lsb_release -cs: на Debian это ломает apt update, а docker-ce конфликтует с docker.io.

Второй скрипт, сам Obsidian-стек, открывает порты 5984 и 8787 в интернет по голому HTTP, выдаёт клиенту Obsidian админские учётные данные CouchDB, не создаёт том для данных MCP, из-за чего индекс и OAuth-клиенты теряются при рестарте, использует тег :latest и называет MCP_AUTH_TOKEN токеном, хотя это пароль для формы OAuth.

Сам проект obsidian-sync-mcp хороший: OAuth 2.1 с PKCE, ротация refresh-токенов, защита от DNS-rebinding, timing-safe сравнение паролей. Плохи были только обёртки. Если инструкция предлагает запустить скрипт от root и не показывает, что внутри, открой и посмотри. Обычно там ничего страшного. Иногда там ufw --force reset.

Сервер

Что нужно заранее

Сервер с Debian или Ubuntu и Docker, nginx, поддомен с A-записью на сервер, certbot и свободные на loopback порты 5984 и 8787:

ss -tlnp | grep -E ':(5984|8787)\b' || echo "оба свободны"

Памяти связка ест около 400-600 МБ.

Плагин docker compose, не ломая системный Docker

Если Docker стоит из пакета docker.io, команды docker compose нет. Ставить docker-compose-plugin из репозитория Docker нельзя: он тянет docker-ce, который конфликтует с docker.io, и apt может снести работающий Docker. Правильно положить один бинарник:

VER=$(curl -s https://api.github.com/repos/docker/compose/releases/latest \
      | grep -oP '"tag_name":\s*"\K[^"]+')
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL "https://github.com/docker/compose/releases/download/${VER}/docker-compose-linux-$(uname -m)" \
     -o /usr/local/lib/docker/cli-plugins/docker-compose
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
docker compose version

Каталог и секреты

mkdir -p /opt/obsidian-sync-mcp && chmod 750 /opt/obsidian-sync-mcp
cd /opt/obsidian-sync-mcp

gen() { tr -dc 'A-Za-z0-9' < /dev/urandom 2>/dev/null | head -c "$1"; }
{
  echo "# Obsidian Sync MCP"
  echo "COUCHDB_USER=admin"
  echo "COUCHDB_PASSWORD=$(gen 32)"
  echo "COUCHDB_DATABASE=obsidian"
  echo "LIVESYNC_USER=livesync"
  echo "LIVESYNC_PASSWORD=$(gen 32)"
  echo "COUCHDB_PASSPHRASE=$(gen 40)"
  echo 'VAULT_NAME="Моё хранилище"'
} > .env
chmod 600 .env
Переменная Кому нужна
COUCHDB_USER / COUCHDB_PASSWORD администратор базы, знает только сервер и MCP
LIVESYNC_USER / LIVESYNC_PASSWORD ограниченный пользователь, вводится в Obsidian на устройствах
COUCHDB_PASSPHRASE ключ E2E-шифрования, одинаковый на всех устройствах и в MCP
VAULT_NAME имя хранилища для ссылок obsidian://, должно точно совпадать с именем в Obsidian

Пароли только из букв и цифр: спецсимволы ничего не добавляют к длине 32, зато ломают экранирование в URL и в .env. Конструкция tr ... | head -c под set -o pipefail валит скрипт: tr получает SIGPIPE, код 141. А VAULT_NAME с пробелами обязательно в кавычках, иначе set -a; . ./.env в bash ломается на пробеле, docker compose кавычки читает правильно.

Конфиг CouchDB

Монтируется каталог, а не одиночный файл, и на запись (почему, смотри в граблях ниже):

mkdir -p /opt/obsidian-sync-mcp/couchdb-local.d

Файл couchdb-local.d/livesync.ini:

[couchdb]
single_node = true
max_document_size = 50000000

[chttpd]
require_valid_user = true
max_http_request_size = 4294967296
enable_cors = true
bind_address = 0.0.0.0

[chttpd_auth]
require_valid_user = true

[httpd]
WWW-Authenticate = Basic realm="couchdb"
enable_cors = true
bind_address = 0.0.0.0

[cors]
; Origin'ы клиентов Obsidian: десктоп и мобильный (capacitor).
origins = app://obsidian.md,capacitor://localhost,http://localhost
credentials = true
methods = GET, PUT, POST, HEAD, DELETE
headers = accept, authorization, content-type, origin, referer, cache-control
max_age = 3600

Адрес bind_address = 0.0.0.0 действует внутри контейнера, наружу его ограничивает compose. Строка capacitor://localhost обязательна для iOS и Android, без неё мобильный Obsidian не подключится. Проверить CORS так, как это делает iPhone:

curl -s -o /dev/null -D - -X OPTIONS -H 'Origin: capacitor://localhost' \
  -H 'Access-Control-Request-Method: GET' https://obsidian.example.com/obsidian \
  | grep -i access-control-allow-origin

docker-compose.yml

name: obsidian-sync-mcp

services:
  couchdb:
    image: couchdb:3.5.2.1
    container_name: obsidian-couchdb
    restart: unless-stopped
    ports:
      - "127.0.0.1:5984:5984"
    environment:
      COUCHDB_USER: ${COUCHDB_USER}
      COUCHDB_PASSWORD: ${COUCHDB_PASSWORD:?COUCHDB_PASSWORD не задан в .env}
    volumes:
      - couchdb_data:/opt/couchdb/data
      - ./couchdb-local.d:/opt/couchdb/etc/local.d
    healthcheck:
      test: ["CMD", "curl", "-fsS", "-u", "${COUCHDB_USER}:${COUCHDB_PASSWORD}", "http://127.0.0.1:5984/_up"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 20s
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }

  mcp:
    image: ghcr.io/es617/obsidian-sync-mcp:v0.6.2
    container_name: obsidian-mcp
    restart: unless-stopped
    ports:
      - "127.0.0.1:8787:8787"
    environment:
      COUCHDB_URL: http://couchdb:5984
      COUCHDB_USER: ${COUCHDB_USER}
      COUCHDB_PASSWORD: ${COUCHDB_PASSWORD}
      COUCHDB_DATABASE: ${COUCHDB_DATABASE}
      COUCHDB_PASSPHRASE: ${COUCHDB_PASSPHRASE}
      VAULT_NAME: ${VAULT_NAME}
      BASE_URL: http://127.0.0.1:8787
      DATA_DIR: /data
    volumes:
      - mcp_data:/data
    depends_on:
      couchdb:
        condition: service_healthy
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }

volumes:
  couchdb_data:
  mcp_data:

Три принципиальных момента: 127.0.0.1: перед каждым портом, закреплённые версии вместо :latest, том mcp_data вместе с DATA_DIR, иначе индекс и OAuth-клиенты теряются при рестарте.

docker compose config --quiet && echo "конфиг валиден"
docker compose up -d && docker compose ps

База и ограниченный пользователь

Клиентам Obsidian админа не давать: логин лежит на телефоне и ноутбуке, это самое вероятное место утечки.

cd /opt/obsidian-sync-mcp && set -a; . ./.env; set +a
A="http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@127.0.0.1:5984"

for db in _users _replicator "$COUCHDB_DATABASE"; do
  curl -s -o /dev/null -w "$db → %{http_code}\n" -X PUT "$A/$db"   # 412 = уже есть
done

curl -s -X PUT "$A/_users/org.couchdb.user:${LIVESYNC_USER}" -H 'Content-Type: application/json' \
  -d "{\"name\":\"${LIVESYNC_USER}\",\"password\":\"${LIVESYNC_PASSWORD}\",\"roles\":[],\"type\":\"user\"}"

curl -s -X PUT "$A/${COUCHDB_DATABASE}/_security" -H 'Content-Type: application/json' \
  -d "{\"admins\":{\"names\":[],\"roles\":[\"_admin\"]},\"members\":{\"names\":[\"${LIVESYNC_USER}\"],\"roles\":[]}}"

Права livesync проверяются так: своя база даёт 200, _all_dbs даёт 401 или 403, создание базы тоже 401 или 403, аноним получает 401.

Запомнить. Документ _security хранится внутри базы. Если базу удалить и создать заново, а плагин именно так делает при первой загрузке, права сбросятся к "только админы". Сохрани _security заранее и верни потом.

Переменная LIVESYNC_USER в docker-compose.yml проекта не обрабатывается, она живёт только в entrypoint для Fly.io, поэтому пользователя создаём руками.

nginx и TLS

Порядок важен: сначала HTTP-vhost, reload, потом certbot. Если в nginx есть default_server с return 444, запрос к новому домену обрывается, и ACME-проверка падает с загадочным "HTTP 000".

Сначала временный vhost, в котором открыт только /.well-known/acme-challenge/ из /var/www/html, остальное отдаёт 404. Проверка:

echo acme-ok > /var/www/html/.well-known/acme-challenge/probe
curl -s http://obsidian.example.com/.well-known/acme-challenge/probe

Сертификат через --webroot, чтобы certbot не правил конфиги nginx:

certbot certonly --webroot -w /var/www/html -d obsidian.example.com \
  --non-interactive --agree-tos --keep-until-expiring

Боевой vhost:

server {
    listen 80;
    listen [::]:80;
    server_name obsidian.example.com;
    location /.well-known/acme-challenge/ { root /var/www/html; }
    location / { return 301 https://$host$request_uri; }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name obsidian.example.com;

    ssl_certificate     /etc/letsencrypt/live/obsidian.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/obsidian.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_tickets off;
    add_header Strict-Transport-Security "max-age=31536000" always;

    # LiveSync шлёт документы пачками; лимит документа CouchDB 50 МБ.
    client_max_body_size 128M;

    access_log /var/log/nginx/obsidian.access.log;
    error_log  /var/log/nginx/obsidian.error.log;

    # Админка и служебные адреса наружу не нужны.
    location /_utils         { deny all; }
    location = /_all_dbs     { deny all; }
    location /_users         { deny all; }
    location /_node          { deny all; }
    location /_cluster_setup { deny all; }

    location / {
        proxy_pass http://127.0.0.1:5984;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # /_changes висит долгоживущими запросами.
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
        proxy_buffering off;
        proxy_request_buffering off;
    }
}

У строки location /_node { deny all; } есть важное следствие: проверка сервера в мастере плагина читает /_node/_local/_config и всегда будет падать. Это нормально, подробности ниже. Отдельные логи для сайта удобны, по ним потом и шла вся диагностика клиентов.

Дальше nginx -t && systemctl reload nginx каждый раз. Если конфиг сломан, работающий nginx держит старую конфигурацию, но при следующем рестарте не поднимется и утянет все сайты на сервере.

Deploy-хук /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh, чтобы nginx перечитывал продлённый сертификат:

#!/bin/sh
set -e
if ! nginx -t 2>/dev/null; then
    echo "reload-nginx: конфиг не проходит проверку, reload пропущен" >&2
    exit 0
fi
systemctl reload nginx

И потом certbot renew --dry-run.

Проверки сервера

HTTPS с логином livesync даёт 200, без пароля 401, /_utils/ 403, HTTP редиректит 301. Порты 5984 и 8787 снаружи закрыты:

for p in 5984 8787; do timeout 6 bash -c "</dev/tcp/ВНЕШНИЙ_IP/$p" 2>/dev/null \
  && echo "$p ОТКРЫТ" || echo "$p закрыт"; done

MCP отвечает на initialize, а с подменённым Host: evil.example.com даёт 403. И в базе нет открытого текста заметок.

Грабли сервера

CouchDB молча падает, если ini смонтирован в режиме :ro. Контейнер перезапускается, код 1, docker logs пустой. Entrypoint делает chown по /opt/couchdb под set -e, а на read-only он не проходит.

CouchDB пишет в свой конфиг: дописывает uuid, secret и создаёт docker.ini с хешем пароля админа. Каталог конфигов содержит секреты, в git его нельзя. Если монтировать одиночный файл, хеш пропишется прямо в него.

Docker публикует порты мимо UFW. Если iptables -S DOCKER-USER показывает только -j RETURN, то ports: "5984:5984" открывает базу всему интернету, а ufw status будет показывать "закрыто". Отсюда 127.0.0.1: в каждой публикации.

Без тома DATA_DIR у MCP при каждом up теряются индекс и OAuth-регистрации, ИИ-клиент получает "Unknown client". И ещё: MCP_AUTH_TOKEN это пароль, а не bearer-токен, он вводится один раз в браузере в форме OAuth.

Бэкап и проверка восстановления

Всё это сделано до подключения устройств.

Скрипт /usr/local/bin/obsidian-backup.sh с правами 700:

#!/bin/bash
set -euo pipefail
umask 077

cd /opt/obsidian-sync-mcp
set -a; . ./.env; set +a

DEST=/var/backups/obsidian
DAY=$(date +%F)
mkdir -p "$DEST"; chmod 700 "$DEST"

# Логин передаём curl через stdin (-K -), чтобы он не светился в списке процессов.
couch() {
  printf 'user = "%s:%s"\n' "$COUCHDB_USER" "$COUCHDB_PASSWORD" \
    | docker exec -i obsidian-couchdb curl -fsS -K - "http://127.0.0.1:5984/$1"
}

fail() { logger -t obsidian-backup -p user.err "FAILED: $*"; echo "obsidian-backup: $*" >&2; exit 1; }
trap 'fail "line $LINENO"' ERR

TMP=$(mktemp "$DEST/.tmp-XXXXXX")
trap 'rm -f "$TMP"' EXIT

couch "${COUCHDB_DATABASE}/_all_docs?include_docs=true" | gzip > "$TMP"
ROWS=$(zcat "$TMP" | jq -e '.rows | length') || fail "dump is not valid JSON"
[ "$ROWS" -gt 0 ] || fail "dump has no rows"
mv "$TMP" "$DEST/obsidian-$DAY.json.gz"

# Права базы и пользователь livesync: без них восстановленная база не примет клиентов.
couch "${COUCHDB_DATABASE}/_security" > "$DEST/security-$DAY.json"
couch "_users/org.couchdb.user:${LIVESYNC_USER}" > "$DEST/livesync-user-$DAY.json"

find "$DEST" -maxdepth 1 -type f \( -name 'obsidian-*.json.gz' -o -name 'security-*.json' \
  -o -name 'livesync-user-*.json' \) -mtime +30 -delete

logger -t obsidian-backup "OK: $ROWS docs -> $DEST/obsidian-$DAY.json.gz"

Чем он отличается от наивного curl | gzip. Связка curl -fsS и pipefail роняет скрипт при ошибке CouchDB, вместо того чтобы сохранить пустой файл. Временный файл и проверка rows > 0 не дают битому дампу затереть последний хороший. Рядом сохраняются _security и пользователь livesync с хешем, а не паролем, и это пригодилось уже через час, когда база пересоздалась. Логин уезжает в curl -K -, а не в URL, поэтому его не видно в ps. Результат идёт в syslog, читается через journalctl -t obsidian-backup.

Cron /etc/cron.d/obsidian-backup гоняет скрипт раз в сутки ночью от root. Внеплановый бэкап это просто запуск скрипта, и делать его стоит перед каждым рискованным действием.

Бэкап, который ни разу не разворачивали, это не бэкап. Восстанавливаем в тестовую базу:

set -a; . ./.env; set +a
c() { printf 'user = "%s:%s"\n' "$COUCHDB_USER" "$COUCHDB_PASSWORD" \
      | docker exec -i obsidian-couchdb curl -sS -K - "$@"; }
T=obsidian_restore_test; U=http://127.0.0.1:5984
c -X PUT "$U/$T"
zcat /var/backups/obsidian/obsidian-YYYY-MM-DD.json.gz \
  | jq -c '{docs: [.rows[].doc], new_edits: false}' \
  | docker exec -i obsidian-couchdb sh -c 'cat > /tmp/restore.json'
c -X POST -H 'Content-Type: application/json' --data-binary @/tmp/restore.json "$U/$T/_bulk_docs"
docker exec obsidian-couchdb rm -f /tmp/restore.json
c "$U/$T" | jq .doc_count     # должен совпасть с боевой базой
c -X DELETE "$U/$T"

Мелочи по ходу: _bulk_docs с new_edits:false при успехе отвечает []; stdin у curl занят конфигом -K -, поэтому тело кладём файлом внутрь контейнера; _all_docs не отдаёт _local-документы, это нормально. Полная проверка будет после первого устройства, когда станет видно, что заметки расшифровываются той же passphrase.

Главное про бэкап: дамп зашифрован passphrase. Passphrase хранить отдельно от сервера, в менеджере паролей, можно ещё бумажную копию. Бэкап рядом с .env на одном диске это единая точка отказа, копия за пределы сервера остаётся отдельным шагом.

Как перенести сложные пароли на телефон

Пароли по 32-40 случайных символов вручную на iPhone не набрать. Сохранять их надо в менеджер паролей. На iPhone есть встроенное приложение "Пароли" (iOS 18 и новее): бесплатно, синхронизация через iCloud Keychain со сквозным шифрованием, есть на Mac и Windows. Если основной компьютер на Windows или Linux, подойдёт Bitwarden. Сохранить стоит COUCHDB_PASSPHRASE (самое важное, не восстанавливается), пару LIVESYNC_USER и LIVESYNC_PASSWORD для входа с устройств и COUCHDB_PASSWORD для восстановления сервера.

Переношу я их QR-кодами. Маленький скрипт на сервере читает .env и рисует по картинке на ключ. Отдельное окружение Python лежит в каталоге проекта (tools/.venv), а не в системе: там segno для QR и Pillow для подписи, зависимости в tools/requirements.txt. В QR кладётся только значение, чтобы при сканировании копировался чистый пароль без имени, а имя переменной идёт подписью над кодом; для варианта KEY=value есть опция --with-name. Картинки лежат в каталоге с правами 700, файлы 600, ключ --clean удаляет их после сканирования. Скрипт печатает только пути к картинкам, значения никуда не выводит. Дальше камера iPhone, жёлтая подпись, "Скопировать" и вставка в запись "Паролей".

Если работаешь с ИИ-ассистентом в редакторе. В VS Code с расширением Claude выделение текста автоматически уходит ассистенту в контекст. Выделил строку в .env, чтобы скопировать, и секрет уже в переписке. Копируй без выделения мышью: курсор после =, Shift+End, Ctrl+C, и сразу снять выделение. А самому ассистенту закрыть чтение .env в настройках прав.

Первое устройство: ПК

Кто первый, тот заливает хранилище на сервер. В базе ещё ничего нет, поэтому первым беру устройство, где удобнее вводить данные, то есть ПК. Сначала пробовал iPhone: ввод сложных паролей на телефоне и падающая проверка сервера заставили переключиться.

Хранилище создаётся обычное, в обычной папке, не в OneDrive, Dropbox или iCloud Drive: одно хранилище синхронизируется одним способом. Дальше шестерёнка слева внизу, "Сторонние плагины", включить их, "Обзор", Self-hosted LiveSync, "Установить" и "Включить".

Мастер настройки

Плагин переведён на русский частично. На первом экране выбираю "Я настраиваю это впервые", второй вариант про добавление устройства пригодится позже для телефона.

Экран "Сквозное шифрование": ставлю галочку, в появившемся поле "Парольная фраза" вставляю COUCHDB_PASSPHRASE, только значение, без имени и =, и сверяю края через кнопку показа пароля. Пункт Obfuscate Properties выключен, с ним MCP не найдёт заметки. Advanced не трогаю, алгоритм по умолчанию и E2EE v2 с MCP работают. Плагин честно предупреждает, что passphrase не проверяется до начала синхронизации и ошибка всплывёт только потом, поэтому её надо вставлять, а не набирать.

В способе подключения выбираю "Configure a remote manually" и CouchDB: Setup URI ещё нет, его создаст первое устройство.

На экране CouchDB Configuration: URL https://obsidian.example.com без слеша в конце, имя пользователя livesync (не admin), пароль LIVESYNC_PASSWORD, имя базы obsidian, Use Internal API выключено, CORS на сервере уже настроен. Кнопка Check server requirements скажет "Проверка конфигурации не удалась", и это нормально: проверка читает /_node/_local/_config, а nginx его закрывает, в логе видно 403. Кнопку Fix жать не нужно, реальная проверка это следующая кнопка, Create or connect to database and continue.

Последний экран, "Final Confirmation: Overwrite Server Data with This Device's Files": три галочки, включая "I have created a backup of my Vault", и кнопка I Understand, Overwrite Server.

Ловушка 1: Overwrite Server требует прав администратора CouchDB

Симптом: мастер прошёл, внизу синхронизация стоит на нуле и значок перечёркнут красным, заметки на сервер не уходят. Сохранения не помогают.

Причина видна по логу nginx: "Overwrite Server" сначала удаляет базу (DELETE /obsidian/), а потом создаёт заново. У livesync прав на это нет, отсюда 401. Плагин остаётся в состоянии "первая загрузка не завершена" и ставит себя на паузу.

Лечение такое, что пароль админа на ПК не попадает, а окно риска измеряется минутами. Сначала бэкап и копия каталога couchdb-local.d/. Потом временно делаю livesync администратором сервера:

set -a; . ./.env; set +a
jq -n --arg p "$LIVESYNC_PASSWORD" '$p' | docker exec -i obsidian-couchdb sh -c 'cat > /tmp/p.json'
printf 'user = "%s:%s"\n' "$COUCHDB_USER" "$COUCHDB_PASSWORD" \
  | docker exec -i obsidian-couchdb curl -fsS -K - -X PUT --data-binary @/tmp/p.json \
    "http://127.0.0.1:5984/_node/_local/_config/admins/${LIVESYNC_USER}"
docker exec obsidian-couchdb rm -f /tmp/p.json

Выдать роль _admin через документ пользователя в _users нельзя, CouchDB отвечает 403. Изменение применяется через пару секунд, проверить можно запросом GET /_session, там должно появиться "roles":["_admin"].

Дальше на ПК: "Настройки", "Self-hosted LiveSync", "Перезапустить мастер настройки" и всё заново. Теперь в логе DELETE /obsidian/ 200, PUT /obsidian/ 201, _bulk_docs 201. Окно "All optional features are disabled" про выключенные Customisation Sync и Hidden File Sync закрываю кнопкой OK: для заметок они не нужны, наборы плагинов на ПК и телефоне всё равно разные.

И сразу снимаю права (префикс с printf и docker exec тот же, что выше):

... curl -K - -X DELETE "http://127.0.0.1:5984/_node/_local/_config/admins/${LIVESYNC_USER}"

Как сделать проще: выдать livesync права администратора до мастера и снять после. Это экономит час.

Ловушка 2: пересозданная база теряет права доступа

После удаления и создания база получает _security по умолчанию, где в members.roles стоит ["_admin"]. Как только livesync перестаёт быть админом, он получает 401 на свою же базу.

Лечение: вернуть _security из бэкапа, вот зачем скрипт сохраняет его отдельно.

docker exec -i obsidian-couchdb sh -c 'cat > /tmp/sec.json' < /var/backups/obsidian/security-YYYY-MM-DD.json
... curl -K - -X PUT -H 'Content-Type: application/json' --data-binary @/tmp/sec.json \
    "http://127.0.0.1:5984/obsidian/_security"

Проверка: у livesync своя база отвечает 200, _all_dbs даёт 401, в _session нет _admin.

Ловушка 3: MCP перестал расшифровывать заметки

Симптом: list_notes показывает заметки, read_note отвечает "Note not found", в логах MCP Decryption with HKDF failed.

Причина: у новой базы новая соль шифрования, она лежит в _local/obsidian_livesync_sync_parameters, поле pbkdf2salt. Запущенный MCP помнит старую.

Лечение: пересоздать контейнер MCP командой docker compose up -d --force-recreate mcp. Заодно я поменял VAULT_NAME на реальное имя хранилища. После старта в логах появляется Synchronisation parameters fetched successfully и число проиндексированных заметок.

Ловушка 4: Synchronisation paused for compatibility review

Окно появляется после пересоздания базы: плагин просит обновить плагин на всех устройствах и держит синхронизацию на паузе. На единственном устройстве безопасно нажать Resume synchronisation. Крестик и фиолетовая кнопка Keep synchronisation paused оставляют паузу. Вернуться к окну можно командой Review why synchronisation is paused, но она видна, только пока пауза активна: если в палитре "Команда не найдена", значит паузы уже нет.

Ловушка 5: "Диагностика настроек" ломает синхронизацию

Симптом: режим LiveSync выбран, пауза снята, но заметки не уходят. В логе nginx при каждом сохранении только GET/PUT _local/obsydian_livesync_milestone, без _bulk_docs и _changes. В журнале плагина сначала "Some mismatches have been detected in the configuration between devices. Running a manual replication will attempt to resolve this issue.", а затем "Не удалось подключиться к серверу". Это сообщение ложное, подключение в порядке.

Причина: в Hatch была нажата "Диагностика настроек", и она "исправила" customChunkSize с 0 на 60. В базе записано 0. Автосинхронизация при расхождении молча отказывается работать.

Лечение: палитра команд, Self-hosted LiveSync: Sync now, то есть ручная синхронизация. Плагин сам включает "Automatic alignment of compatible chunk settings", обновляет настройки в базе и запускает LiveSync. Окна с таблицей может и не быть. Не принимай предложения "Диагностики" менять customChunkSize на уже работающей базе.

Ловушка 6: после Fetch и Overwrite автосинхронизация выключена

Перед загрузкой плагин вызывает suspendAllSync() и выключает LiveSync, периодическую синхронизацию, sync on save и sync on start. После загрузки он их не включает обратно, в исходниках CLI-версии плагина так и написано: "suspendAllSync() clobbers them". Итог: синхронизация только по ручной команде.

Лечение: после мастера на каждом устройстве зайти в "Настройки синхронизации" и заново выбрать режим. Похожие жалобы есть в issue плагина.

Как диагностировать

Источника правды два, и это главный навык во всей истории.

Первый, лог nginx /var/log/nginx/obsidian.access.log. По нему видно, есть ли запросы от устройства вообще: User-Agent у ПК выглядит как obsidian/1.13.7 ... Electron, у телефона как iPhone ... obsidian. Дальше по запросам: _bulk_docs с кодом 201 означает, что данные ушли, _changes означает обмен, одни только milestone говорят, что плагин проверил базу и сам остановился, 401 это права, 403 на /_node это закрытая админка.

Второй, журнал самого плагина: палитра команд (Ctrl+P, на iPhone свайп вниз по заметке) и Self-hosted LiveSync: Show log. Названия команд плагина не переводятся, искать надо по-английски: Show log, Sync now.

В строке состояния внизу перечёркнутый красный значок и нули у счётчиков означают, что синхронизация стоит.

Второе устройство: iPhone

Если от неудачной первой попытки осталось хранилище, удали его в "Управлении хранилищами". Новое создаётся с тем же именем, что на ПК, тогда ссылки obsidian:// от ИИ открываются и на телефоне. Пункт "Хранить в iCloud" выключить: iCloud вместе с LiveSync дают дубликаты и конфликты. Плагин ставится так же, как на ПК.

Настройки передаются с ПК из раздела "Для настройки других устройств". Кнопка "Показать QR код" в русском интерфейсе показывает окно "Мы сгенерировали QR-код... Отсканируйте QR-код телефоном" без самого QR-кода, а OK просто закрывает окно. Причина в том, что в русском переводе строки Setup.QRCode потерян плейсхолдер ${qr_image}, в английском он есть. Обход: "Внешний вид", язык плагина English, показать QR, вернуть язык. Важно, что этот QR не зашифрован, в нём все настройки включая пароли, поэтому только со своего экрана, не фотографировать и не пересылать.

Альтернатива это "Копировать текущие настройки в Setup URI". Строка зашифрована паролем, который ты придумываешь сам, её можно переслать себе, например в "Избранное" мессенджера. Setup URI и пароль к нему стоит сохранить в менеджер паролей, пригодятся для следующих устройств.

В мастере на телефоне: "Я добавляю устройство к существующей настройке синхронизации", дальше QR или Setup URI и пароль. На экране "Mostly Complete: Decision Required" нужен вариант "My remote server is already set up. I want to join this device." Не первый, потому что "I am setting up a new server... / reset" перезапишет сервер пустым телефоном, и не третий, плагин сам называет его опасным.

Дальше "Data retrieval scheduled" и Overwrite all with remote files. Overwrite здесь про файлы на iPhone, сервер не трогается. Вариант "Compare time and take newer" не подходит: Obsidian кладёт в каждое новое хранилище заметку "Добро пожаловать", на iPhone она новее серверной и победила бы. Если появится окно про compatibility review, жми Resume.

После Fetch iPhone один раз синхронизировался и замолчал: правка с телефона на ПК не пришла. По логу nginx после Fetch от iPhone не было ни одного запроса. Ручной Sync now отправлял правку, а следующую уже нет. Причина та же, что и на ПК, шестая ловушка: Fetch выключил автосинхронизацию.

Правильные режимы синхронизации

Идти надо в "Настройки", "Self-hosted LiveSync", "Настройки синхронизации", "Режим синхронизации".

Устройство Режим Дополнительно
ПК LiveSync (непрерывно) Keep replication active in the background включить
iPhone LiveSync Sync on Startup, Sync on File Open, Sync on Save включить

Даже если в поле уже стоит LiveSync, после мастера выбери его заново, чтобы он применился.

Свёрнутый Obsidian на телефоне замораживается через несколько секунд, это ограничение iOS, а не плагина. Правка, сделанная прямо перед сворачиванием, уйдёт при следующем открытии, через Sync on Startup. В итоге пишешь заметку, и через секунду-две она на сервере и на другом устройстве, без нажатий.

Мелочи, которые стоит знать

Пустые папки не синхронизируются, LiveSync передаёт файлы. Новая папка появится на других устройствах и в MCP, когда в ней будет хоть одна заметка.

Переменная VAULT_NAME должна точно совпадать с именем хранилища в Obsidian, иначе ссылки obsidian://open?vault=... не открываются. Смена имени даёт новый подкаталог в томе MCP: поисковый индекс пересоберётся, а OAuth-клиентов придётся переподключить.

Команда docker compose restart не перечитывает .env, нужен docker compose up -d.

Удаление заметки через MCP уходит в базу, и LiveSync удаляет её на всех устройствах.

Лимиты: документ CouchDB 50 МБ, client_max_body_size 128M. Большие вложения раздувают базу.

Инструкции для ИИ можно держать на сервере (MCP_INSTRUCTIONS_FILE), а запись ограничить папкой (WRITE_FOLDERS), это пригодится при открытии MCP наружу.

Что даёт E2E-шифрование, а что нет

Защищено содержимое заметок: утечка логина с телефона даёт только шифротекст, дамп без passphrase бесполезен.

Не защищены пути и имена файлов, они лежат в базе открытым текстом, вида "path":"папка/заметка.md". Скрыть их можно через Path Obfuscation, но тогда не работает MCP, выбирать приходится. И root на сервере: passphrase лежит в .env для MCP, значит, это уже шифрование при хранении, а не строго end-to-end.

Итоговая проверка

Заметка, созданная на ПК, через 1-2 секунды читается через MCP с сервера, и раз она расшифровывается, то passphrase и Path Obfuscation в порядке. Правка на iPhone приходит на ПК без ручных команд. Пользователь livesync не админ, _all_dbs для него 401, админка снаружи 403. Бэкап по расписанию работает, восстановление проверено.

Что дальше

Открыть MCP наружу через nginx с OAuth (MCP_AUTH_TOKEN, BASE_URL), чтобы Claude в браузере, десктопе и на телефоне мог записывать мысли в Inbox-папку, а запись ограничить через WRITE_FOLDERS. Защиты от перебора пароля на форме входа MCP нет, понадобится limit_req в nginx, при этом fail2ban не должен забанить адреса Claude.

Копия бэкапа за пределы сервера через rsync или rclone. Fail2ban на 401 для CouchDB и ротация логов nginx для этого сайта.