feat: 导入 MemRelay 初始源码
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
param(
|
||||
[string]$ComposeFile = "compose.yaml",
|
||||
[string]$DataPath = "./data",
|
||||
[string]$OutputDirectory = "./backups"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ")
|
||||
$resolvedData = (Resolve-Path -LiteralPath $DataPath).Path
|
||||
$output = [System.IO.Path]::GetFullPath($OutputDirectory)
|
||||
New-Item -ItemType Directory -Force -Path $output | Out-Null
|
||||
$archive = Join-Path $output "memrelay-$timestamp.tar.gz"
|
||||
$manifest = Join-Path $output "memrelay-$timestamp.json"
|
||||
$archiveName = [System.IO.Path]::GetFileName($archive)
|
||||
$images = @(& docker compose -f $ComposeFile config --images)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to read Compose image versions."
|
||||
}
|
||||
|
||||
& docker compose -f $ComposeFile exec -T memrelay python -c `
|
||||
"import os, sqlite3; p=os.path.join(os.environ.get('MEMRELAY_DATA_DIR', '/data/memrelay'), 'db', 'memrelay.sqlite3'); c=sqlite3.connect(p); print(c.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone()); c.close()" | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to checkpoint the MemRelay database."
|
||||
}
|
||||
|
||||
docker compose -f $ComposeFile stop | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to stop MemRelay services."
|
||||
}
|
||||
try {
|
||||
& docker run --rm --user "1000:1000" `
|
||||
--mount "type=bind,source=$resolvedData,target=/source,readonly" `
|
||||
--mount "type=bind,source=$output,target=/backup" `
|
||||
alpine:3.22 tar -czf "/backup/$archiveName" -C /source .
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to create the backup archive."
|
||||
}
|
||||
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant()
|
||||
@{
|
||||
product = "MemRelay"
|
||||
created_at = (Get-Date).ToUniversalTime().ToString("o")
|
||||
archive = $archiveName
|
||||
sha256 = $hash
|
||||
compose_file = $ComposeFile
|
||||
images = $images
|
||||
} | ConvertTo-Json | Set-Content -Encoding UTF8 -LiteralPath $manifest
|
||||
"$hash $archiveName" | Set-Content -Encoding ASCII -LiteralPath "$archive.sha256"
|
||||
Write-Host "Backup created: $archive"
|
||||
}
|
||||
catch {
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $archive, $manifest, "$archive.sha256"
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
docker compose -f $ComposeFile start | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Backup finished, but MemRelay services could not be restarted."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
compose_file="${COMPOSE_FILE:-compose.yaml}"
|
||||
data_path="${MEMRELAY_DATA_PATH:-./data}"
|
||||
output_dir="${BACKUP_DIR:-./backups}"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
archive="$output_dir/memrelay-$timestamp.tar.gz"
|
||||
manifest="$output_dir/memrelay-$timestamp.json"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
data_path="$(cd "$data_path" && pwd -P)"
|
||||
output_dir="$(cd "$output_dir" && pwd -P)"
|
||||
archive="$output_dir/memrelay-$timestamp.tar.gz"
|
||||
manifest="$output_dir/memrelay-$timestamp.json"
|
||||
images="$(docker compose -f "$compose_file" config --images | sort | paste -sd ',' -)"
|
||||
docker compose -f "$compose_file" exec -T memrelay python -c \
|
||||
"import os, sqlite3; p=os.path.join(os.environ.get('MEMRELAY_DATA_DIR', '/data/memrelay'), 'db', 'memrelay.sqlite3'); c=sqlite3.connect(p); print(c.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone()); c.close()"
|
||||
docker compose -f "$compose_file" stop
|
||||
trap 'docker compose -f "$compose_file" start' EXIT INT TERM
|
||||
if ! docker run --rm --user "$(id -u):$(id -g)" \
|
||||
--mount "type=bind,source=$data_path,target=/source,readonly" \
|
||||
--mount "type=bind,source=$output_dir,target=/backup" \
|
||||
alpine:3.22 tar -czf "/backup/$(basename "$archive")" -C /source .; then
|
||||
rm -f "$archive" "$archive.sha256" "$manifest"
|
||||
exit 1
|
||||
fi
|
||||
hash="$(sha256sum "$archive" | awk '{print $1}')"
|
||||
printf '%s %s\n' "$hash" "$(basename "$archive")" > "$archive.sha256"
|
||||
printf '{"product":"MemRelay","created_at":"%s","archive":"%s","sha256":"%s","compose_file":"%s","images":"%s"}\n' \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(basename "$archive")" "$hash" "$compose_file" "$images" > "$manifest"
|
||||
echo "Backup created: $archive"
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
createCipheriv,
|
||||
createHmac,
|
||||
generateKeyPairSync,
|
||||
pbkdf2Sync,
|
||||
randomBytes,
|
||||
} from "node:crypto";
|
||||
|
||||
|
||||
function hkdfExpand(key, info, length) {
|
||||
const output = [];
|
||||
let previous = Buffer.alloc(0);
|
||||
let counter = 1;
|
||||
while (Buffer.concat(output).length < length) {
|
||||
previous = createHmac("sha256", key)
|
||||
.update(Buffer.concat([previous, Buffer.from(info, "utf8"), Buffer.from([counter])]))
|
||||
.digest();
|
||||
output.push(previous);
|
||||
counter += 1;
|
||||
}
|
||||
return Buffer.concat(output).subarray(0, length);
|
||||
}
|
||||
|
||||
|
||||
function encrypt(plaintext, key) {
|
||||
const encryptionKey = key.subarray(0, 32);
|
||||
const macKey = key.subarray(32, 64);
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv("aes-256-cbc", encryptionKey, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const mac = createHmac("sha256", macKey).update(Buffer.concat([iv, ciphertext])).digest();
|
||||
return `2.${iv.toString("base64")}|${ciphertext.toString("base64")}|${mac.toString("base64")}`;
|
||||
}
|
||||
|
||||
|
||||
async function main() {
|
||||
const [server, rawEmail, password] = process.argv.slice(2);
|
||||
if (!server || !rawEmail || !password) {
|
||||
throw new Error("Usage: create_test_vault_account.mjs <server> <email> <password>");
|
||||
}
|
||||
|
||||
const email = rawEmail.trim().toLowerCase();
|
||||
const iterations = 600_000;
|
||||
const masterKey = pbkdf2Sync(password, email, iterations, 32, "sha256");
|
||||
const masterPasswordHash = pbkdf2Sync(masterKey, password, 1, 32, "sha256").toString(
|
||||
"base64",
|
||||
);
|
||||
const stretchedMasterKey = Buffer.concat([
|
||||
hkdfExpand(masterKey, "enc", 32),
|
||||
hkdfExpand(masterKey, "mac", 32),
|
||||
]);
|
||||
const userKey = randomBytes(64);
|
||||
const key = encrypt(userKey, stretchedMasterKey);
|
||||
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: "spki", format: "der" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "der" },
|
||||
});
|
||||
const response = await fetch(`${server.replace(/\/$/, "")}/identity/accounts/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
name: "MemRelay Stage Zero",
|
||||
masterPasswordHash,
|
||||
masterPasswordHint: null,
|
||||
key,
|
||||
keys: {
|
||||
encryptedPrivateKey: encrypt(privateKey, userKey),
|
||||
publicKey: publicKey.toString("base64"),
|
||||
},
|
||||
kdf: 0,
|
||||
kdfIterations: iterations,
|
||||
kdfMemory: null,
|
||||
kdfParallelism: null,
|
||||
}),
|
||||
});
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vault registration failed (${response.status}): ${body}`);
|
||||
}
|
||||
console.log(body);
|
||||
}
|
||||
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Download and validate the local embedding model used by Basic Memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from fastembed import TextEmbedding
|
||||
|
||||
|
||||
DEFAULT_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
parser.add_argument("--cache-dir", default="/data/basic-memory/cache")
|
||||
parser.add_argument("--threads", type=int, default=2)
|
||||
args = parser.parse_args()
|
||||
|
||||
embedding = TextEmbedding(
|
||||
model_name=args.model,
|
||||
cache_dir=args.cache_dir,
|
||||
threads=args.threads,
|
||||
)
|
||||
vectors = list(embedding.embed(["记忆服务", "memory service"]))
|
||||
if len(vectors) != 2 or len(vectors[0]) != 384:
|
||||
raise RuntimeError("Unexpected embedding model output")
|
||||
print(f"model={args.model} vectors={len(vectors)} dimensions={len(vectors[0])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,52 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Archive,
|
||||
[string]$ComposeFile = "compose.yaml",
|
||||
[string]$DataPath = "./data",
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$resolvedArchive = (Resolve-Path -LiteralPath $Archive).Path
|
||||
$target = [System.IO.Path]::GetFullPath($DataPath)
|
||||
$targetRoot = [System.IO.Path]::GetPathRoot($target).TrimEnd('\', '/')
|
||||
if ($target.TrimEnd('\', '/') -eq $targetRoot) {
|
||||
throw "Refusing to restore into a filesystem root."
|
||||
}
|
||||
$checksumFile = "$resolvedArchive.sha256"
|
||||
if (Test-Path -LiteralPath $checksumFile) {
|
||||
$expected = ((Get-Content -LiteralPath $checksumFile -TotalCount 1) -split '\s+')[0]
|
||||
$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedArchive).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected.ToLowerInvariant()) {
|
||||
throw "Backup checksum verification failed."
|
||||
}
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $target | Out-Null
|
||||
if ((Get-ChildItem -Force -LiteralPath $target | Select-Object -First 1) -and -not $Force) {
|
||||
throw "Target data directory is not empty. Re-run with -Force after verifying the archive."
|
||||
}
|
||||
|
||||
docker compose -f $ComposeFile stop | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to stop MemRelay services."
|
||||
}
|
||||
try {
|
||||
if ($Force) {
|
||||
Get-ChildItem -Force -LiteralPath $target | Remove-Item -Recurse -Force
|
||||
}
|
||||
$archiveDirectory = [System.IO.Path]::GetDirectoryName($resolvedArchive)
|
||||
$archiveName = [System.IO.Path]::GetFileName($resolvedArchive)
|
||||
& docker run --rm --user "1000:1000" `
|
||||
--mount "type=bind,source=$archiveDirectory,target=/backup,readonly" `
|
||||
--mount "type=bind,source=$target,target=/target" `
|
||||
alpine:3.22 tar -xzf "/backup/$archiveName" -C /target
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to extract the backup archive."
|
||||
}
|
||||
Write-Host "Restore completed: $target"
|
||||
}
|
||||
finally {
|
||||
docker compose -f $ComposeFile up -d | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Restore finished, but MemRelay services could not be started."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "Usage: $0 <archive.tar.gz> [--force]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
archive="$1"
|
||||
force="${2:-}"
|
||||
compose_file="${COMPOSE_FILE:-compose.yaml}"
|
||||
data_path="${MEMRELAY_DATA_PATH:-./data}"
|
||||
archive="$(cd "$(dirname "$archive")" && pwd -P)/$(basename "$archive")"
|
||||
|
||||
if [ -f "$archive.sha256" ]; then
|
||||
(cd "$(dirname "$archive")" && sha256sum -c "$(basename "$archive").sha256")
|
||||
fi
|
||||
|
||||
mkdir -p "$data_path"
|
||||
data_path="$(cd "$data_path" && pwd -P)"
|
||||
if [ "$data_path" = "/" ]; then
|
||||
echo "Refusing to restore into the filesystem root." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$(find "$data_path" -mindepth 1 -maxdepth 1 -print -quit)" ] && [ "$force" != "--force" ]; then
|
||||
echo "Target data directory is not empty. Re-run with --force after verifying the archive." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose -f "$compose_file" stop
|
||||
trap 'docker compose -f "$compose_file" up -d' EXIT INT TERM
|
||||
if [ "$force" = "--force" ]; then
|
||||
find "$data_path" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||
fi
|
||||
docker run --rm --user "$(id -u):$(id -g)" \
|
||||
--mount "type=bind,source=$(dirname "$archive"),target=/backup,readonly" \
|
||||
--mount "type=bind,source=$data_path,target=/target" \
|
||||
alpine:3.22 tar -xzf "/backup/$(basename "$archive")" -C /target
|
||||
echo "Restore completed: $data_path"
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Validate the Basic Memory MCP contract used by MemRelay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
REQUIRED_TOOLS = {
|
||||
"delete_note",
|
||||
"list_directory",
|
||||
"move_note",
|
||||
"read_note",
|
||||
"search_notes",
|
||||
"write_note",
|
||||
}
|
||||
|
||||
|
||||
async def call(client: Client, name: str, arguments: dict[str, Any]) -> Any:
|
||||
result = await client.call_tool(name, arguments)
|
||||
if result.data is not None:
|
||||
return result.data
|
||||
return [getattr(item, "text", str(item)) for item in result.content]
|
||||
|
||||
|
||||
async def delete_fixture(client: Client, identifier: str) -> None:
|
||||
await call(
|
||||
client,
|
||||
"delete_note",
|
||||
{"identifier": identifier, "output_format": "json"},
|
||||
)
|
||||
|
||||
|
||||
async def validate(endpoint: str, semantic: bool) -> dict[str, Any]:
|
||||
async with Client(endpoint) as client:
|
||||
tools = {tool.name for tool in await client.list_tools()}
|
||||
missing = REQUIRED_TOOLS - tools
|
||||
if missing:
|
||||
raise RuntimeError(f"Basic Memory is missing required tools: {sorted(missing)}")
|
||||
|
||||
await delete_fixture(client, "Stage Zero Note")
|
||||
for index in range(5):
|
||||
await delete_fixture(client, f"Concurrent Note {index}")
|
||||
await delete_fixture(client, "Credential Memory")
|
||||
await delete_fixture(client, "Weather Memory")
|
||||
|
||||
written = await call(
|
||||
client,
|
||||
"write_note",
|
||||
{
|
||||
"title": "Stage Zero Note",
|
||||
"content": (
|
||||
"# Stage Zero Note\n\n"
|
||||
"中文语义与 English context.\n\n"
|
||||
"- [decision] Markdown remains authoritative."
|
||||
),
|
||||
"directory": "global",
|
||||
"tags": ["stage-zero", "multilingual"],
|
||||
"note_type": "decision",
|
||||
"metadata": {
|
||||
"stable_id": "stage-zero-001",
|
||||
"scope": "global",
|
||||
"revision": 1,
|
||||
"status": "active",
|
||||
},
|
||||
"overwrite": True,
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
read = await call(
|
||||
client,
|
||||
"read_note",
|
||||
{
|
||||
"identifier": "Stage Zero Note",
|
||||
"output_format": "json",
|
||||
"include_frontmatter": True,
|
||||
},
|
||||
)
|
||||
search = await call(
|
||||
client,
|
||||
"search_notes",
|
||||
{
|
||||
"query": "Markdown authoritative",
|
||||
"search_type": "text",
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
concurrent = await asyncio.gather(
|
||||
*(
|
||||
call(
|
||||
client,
|
||||
"write_note",
|
||||
{
|
||||
"title": f"Concurrent Note {index}",
|
||||
"content": f"Concurrent content {index}",
|
||||
"directory": "projects/stage-zero",
|
||||
"metadata": {"request_id": f"stage0-{index}"},
|
||||
"overwrite": True,
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
for index in range(5)
|
||||
)
|
||||
)
|
||||
moved = await call(
|
||||
client,
|
||||
"move_note",
|
||||
{
|
||||
"identifier": "Stage Zero Note",
|
||||
"destination_path": "archive/stage-zero-note.md",
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
archived = await call(
|
||||
client,
|
||||
"read_note",
|
||||
{
|
||||
"identifier": "archive/stage-zero-note",
|
||||
"output_format": "json",
|
||||
"include_frontmatter": True,
|
||||
},
|
||||
)
|
||||
|
||||
semantic_result = None
|
||||
if semantic:
|
||||
await call(
|
||||
client,
|
||||
"write_note",
|
||||
{
|
||||
"title": "Credential Memory",
|
||||
"content": "团队账号密码和访问令牌应统一保存在密码库中。",
|
||||
"directory": "projects/stage-zero",
|
||||
"overwrite": True,
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
await call(
|
||||
client,
|
||||
"write_note",
|
||||
{
|
||||
"title": "Weather Memory",
|
||||
"content": "今天的天气预报是晴天,午后温度较高。",
|
||||
"directory": "projects/stage-zero",
|
||||
"overwrite": True,
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
semantic_result = await call(
|
||||
client,
|
||||
"search_notes",
|
||||
{
|
||||
"query": "Where should team passwords and access tokens be stored?",
|
||||
"search_type": "vector",
|
||||
"min_similarity": 0,
|
||||
"output_format": "json",
|
||||
},
|
||||
)
|
||||
titles = [item["title"] for item in semantic_result.get("results", [])]
|
||||
if "Credential Memory" not in titles:
|
||||
raise RuntimeError("Cross-language semantic search did not return the target note")
|
||||
|
||||
await delete_fixture(client, "Stage Zero Note")
|
||||
for index in range(5):
|
||||
await delete_fixture(client, f"Concurrent Note {index}")
|
||||
await delete_fixture(client, "Credential Memory")
|
||||
await delete_fixture(client, "Weather Memory")
|
||||
|
||||
return {
|
||||
"available_tool_count": len(tools),
|
||||
"write": written,
|
||||
"read": read,
|
||||
"search": search,
|
||||
"concurrent_write_count": len(concurrent),
|
||||
"move": moved,
|
||||
"archive_read": archived,
|
||||
"semantic_search": semantic_result,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--endpoint", default="http://127.0.0.1:8000/mcp")
|
||||
parser.add_argument("--semantic", action="store_true")
|
||||
args = parser.parse_args()
|
||||
print(
|
||||
json.dumps(
|
||||
asyncio.run(validate(args.endpoint, args.semantic)),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
: "${BW_SERVER:?BW_SERVER is required}"
|
||||
: "${BW_EMAIL:?BW_EMAIL is required}"
|
||||
: "${BW_PASSWORD:?BW_PASSWORD is required}"
|
||||
|
||||
if [ "${BW_OFFLINE_ONLY:-0}" = "1" ]; then
|
||||
BW_SESSION="$(bw unlock "$BW_PASSWORD" --raw)"
|
||||
export BW_SESSION
|
||||
items="$(bw list items)"
|
||||
count="$(printf '%s' "$items" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(String(JSON.parse(d).length)))')"
|
||||
if [ "$count" -lt 1 ]; then
|
||||
echo "Offline cache is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Bitwarden CLI offline validation passed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bw logout >/dev/null 2>&1 || true
|
||||
bw config server "$BW_SERVER" >/dev/null
|
||||
|
||||
BW_SESSION="$(bw login "$BW_EMAIL" "$BW_PASSWORD" --raw)"
|
||||
export BW_SESSION
|
||||
bw sync >/dev/null
|
||||
|
||||
item_json='{"type":1,"name":"MemRelay Stage Zero","login":{"username":"stage0","password":"test-secret","totp":"JBSWY3DPEHPK3PXP"}}'
|
||||
encoded="$(printf '%s' "$item_json" | bw encode)"
|
||||
created="$(bw create item "$encoded")"
|
||||
item_id="$(printf '%s' "$created" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).id))')"
|
||||
|
||||
totp="$(bw get totp "$item_id")"
|
||||
case "$totp" in
|
||||
??????) ;;
|
||||
*) echo "Unexpected TOTP response" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
pids=""
|
||||
for _index in 1 2 3 4 5; do
|
||||
bw get item "$item_id" >/dev/null &
|
||||
pids="$pids $!"
|
||||
done
|
||||
for pid in $pids; do
|
||||
wait "$pid"
|
||||
done
|
||||
|
||||
bw lock >/dev/null
|
||||
BW_SESSION="$(bw unlock "$BW_PASSWORD" --raw)"
|
||||
export BW_SESSION
|
||||
bw get item "$item_id" >/dev/null
|
||||
bw sync >/dev/null
|
||||
|
||||
echo "Bitwarden CLI online validation passed"
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
prefix="memrelay-vault-stage0"
|
||||
network="${prefix}-network"
|
||||
vault="${prefix}-server"
|
||||
tls="${prefix}-tls"
|
||||
cli="${prefix}-cli"
|
||||
config_volume="${prefix}-config"
|
||||
npm_volume="${prefix}-npm"
|
||||
email="stage0@memrelay.local"
|
||||
password="MemRelay-Stage0-2026!"
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
script_source="${MEMRELAY_SCRIPT_SOURCE:-$script_dir}"
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$cli" "$tls" "$vault" >/dev/null 2>&1 || true
|
||||
docker volume rm "$config_volume" "$npm_volume" >/dev/null 2>&1 || true
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
cleanup
|
||||
|
||||
docker network create "$network" >/dev/null
|
||||
docker volume create "$config_volume" >/dev/null
|
||||
docker volume create "$npm_volume" >/dev/null
|
||||
|
||||
docker run -d --name "$vault" --network "$network" \
|
||||
-e I_REALLY_WANT_VOLATILE_STORAGE=true \
|
||||
-e SIGNUPS_ALLOWED=true \
|
||||
-e DOMAIN="https://$tls" \
|
||||
vaultwarden/server:1.37.1 >/dev/null
|
||||
|
||||
docker run -d --name "$tls" --network "$network" \
|
||||
-e HTTP_PROXY= -e HTTPS_PROXY= -e http_proxy= -e https_proxy= \
|
||||
caddy:2-alpine caddy reverse-proxy \
|
||||
--from "https://$tls" \
|
||||
--to "http://$vault:80" \
|
||||
--internal-certs >/dev/null
|
||||
|
||||
docker run -d --name "$cli" --network "$network" \
|
||||
-e HTTP_PROXY= -e HTTPS_PROXY= -e http_proxy= -e https_proxy= \
|
||||
-v "$config_volume:/root/.config" \
|
||||
-v "$npm_volume:/root/.npm" \
|
||||
--mount "type=bind,source=$script_source,target=/opt/memrelay-tests,readonly" \
|
||||
node:22-bookworm-slim sh -lc \
|
||||
"npm install -g @bitwarden/cli@2026.7.0 >/tmp/npm-install.log 2>&1 && touch /tmp/ready && sleep infinity" \
|
||||
>/dev/null
|
||||
|
||||
attempt=0
|
||||
until docker exec "$cli" test -f /tmp/ready; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 120 ]; then
|
||||
docker logs "$cli" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker exec "$cli" node /opt/memrelay-tests/create_test_vault_account.mjs \
|
||||
"http://$vault" "$email" "$password" >/dev/null
|
||||
|
||||
docker exec \
|
||||
-e NODE_TLS_REJECT_UNAUTHORIZED=0 \
|
||||
-e BW_SERVER="https://$tls" \
|
||||
-e BW_EMAIL="$email" \
|
||||
-e BW_PASSWORD="$password" \
|
||||
"$cli" sh /opt/memrelay-tests/validate_bitwarden_cli.sh
|
||||
|
||||
docker stop "$tls" >/dev/null
|
||||
docker exec \
|
||||
-e NODE_TLS_REJECT_UNAUTHORIZED=0 \
|
||||
-e BW_SERVER="https://$tls" \
|
||||
-e BW_EMAIL="$email" \
|
||||
-e BW_PASSWORD="$password" \
|
||||
-e BW_OFFLINE_ONLY=1 \
|
||||
"$cli" sh /opt/memrelay-tests/validate_bitwarden_cli.sh
|
||||
|
||||
echo "Vault integration validation passed"
|
||||
Reference in New Issue
Block a user