feat: add Obsidian CLI API service
Build and Push Docker Container / build-and-push (push) Successful in 4m26s
Build and Push Docker Container / build-and-push (push) Successful in 4m26s
- Add Docker image for the official Obsidian desktop app and CLI. - Start Obsidian headlessly with Xvfb and DBus setup. - Expose a safe structured HTTP command API without shell execution. - Add JWT-based vault, path, and command authorization. - Support single-vault and multi-vault container mounts. - Add TypeScript SDK helpers for Obsidian CLI commands. - Add n8n community node package with Obsidian operations. - Add docs, compose config, tests, and production image workflow.
This commit is contained in:
+84
@@ -0,0 +1,84 @@
|
||||
from dataclasses import dataclass
|
||||
import hashlib, base64, hmac
|
||||
from typing import Any
|
||||
import json, time
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.config import JWT_SECRET
|
||||
from app.policy import TokenPolicy, tuple_claim
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
policy:TokenPolicy|None
|
||||
|
||||
def error(status_code:int, detail:str) -> JSONResponse:
|
||||
return JSONResponse({"detail": detail}, status_code=status_code)
|
||||
|
||||
def b64url_decode(value:str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode((value + padding).encode())
|
||||
|
||||
def decode_hs256_jwt(token:str, secret:bytes) -> dict[str, Any]:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError("JWT must have three parts")
|
||||
|
||||
header_b64, payload_b64, signature_b64 = parts
|
||||
try:
|
||||
header = json.loads(b64url_decode(header_b64))
|
||||
payload = json.loads(b64url_decode(payload_b64))
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise ValueError("JWT header or payload is invalid") from exc
|
||||
|
||||
if not isinstance(header, dict) or not isinstance(payload, dict):
|
||||
raise ValueError("JWT header and payload must be JSON objects")
|
||||
if header.get("alg") != "HS256":
|
||||
raise ValueError("only HS256 JWTs are supported")
|
||||
if header.get("typ") not in (None, "JWT"):
|
||||
raise ValueError("JWT typ must be JWT")
|
||||
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||
expected = hmac.new(secret, signing_input, hashlib.sha256).digest()
|
||||
supplied = b64url_decode(signature_b64)
|
||||
if not hmac.compare_digest(supplied, expected):
|
||||
raise ValueError("JWT signature is invalid")
|
||||
|
||||
now = int(time.time())
|
||||
exp = payload.get("exp")
|
||||
if exp is not None and (not isinstance(exp, int | float) or now >= int(exp)):
|
||||
raise ValueError("JWT has expired")
|
||||
nbf = payload.get("nbf")
|
||||
if nbf is not None and (not isinstance(nbf, int | float) or now < int(nbf)):
|
||||
raise ValueError("JWT is not active yet")
|
||||
iat = payload.get("iat")
|
||||
if iat is not None and (not isinstance(iat, int | float) or int(iat) > now + 300):
|
||||
raise ValueError("JWT iat is in the future")
|
||||
|
||||
return payload
|
||||
|
||||
def policy_from_jwt_claims(token:str, claims:dict[str, Any]) -> TokenPolicy:
|
||||
name = str(claims.get("name") or claims.get("sub") or "jwt")
|
||||
return TokenPolicy(
|
||||
token=token,
|
||||
name=name,
|
||||
vaults=tuple_claim(claims.get("vaults"), ("*",)),
|
||||
paths=tuple_claim(claims.get("paths"), ("*",)),
|
||||
commands=tuple_claim(claims.get("commands"), ("*",)),
|
||||
)
|
||||
|
||||
def require_auth(request:Request) -> AuthContext|JSONResponse:
|
||||
if not JWT_SECRET:
|
||||
return error(500, "OBSIDIAN_JWT_SECRET is required")
|
||||
|
||||
header = request.headers.get("authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return error(401, "Unauthorized")
|
||||
|
||||
supplied = header.removeprefix("Bearer ")
|
||||
try:
|
||||
claims = decode_hs256_jwt(supplied, JWT_SECRET.encode())
|
||||
return AuthContext(policy=policy_from_jwt_claims(supplied, claims))
|
||||
except ValueError:
|
||||
return error(401, "Unauthorized")
|
||||
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
import json, os
|
||||
|
||||
JWT_SECRET = os.getenv("OBSIDIAN_JWT_SECRET", "")
|
||||
DEFAULT_TIMEOUT = float(os.getenv("COMMAND_TIMEOUT", "60"))
|
||||
MAX_TIMEOUT = float(os.getenv("MAX_COMMAND_TIMEOUT", "300"))
|
||||
CLI_BIN = os.getenv("OBSIDIAN_CLI_BIN", "obsidian")
|
||||
VAULT_PATH = Path(os.getenv("OBSIDIAN_VAULT_PATH", "/vault")).resolve()
|
||||
HOST = os.getenv("OBSIDIAN_API_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("OBSIDIAN_API_PORT", "8080"))
|
||||
|
||||
def parse_registered_vaults() -> dict[str, str]:
|
||||
raw = os.getenv("OBSIDIAN_VAULTS", "").strip()
|
||||
vaults:dict[str, str] = {}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict):
|
||||
return {str(name): str(path) for name, path in parsed.items()}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
for chunk in raw.replace("\n", ",").split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk or "=" not in chunk:
|
||||
continue
|
||||
name, path = chunk.split("=", 1)
|
||||
vaults[name.strip()] = path.strip()
|
||||
|
||||
if not vaults:
|
||||
vaults[VAULT_PATH.name or "vault"] = str(VAULT_PATH)
|
||||
|
||||
return vaults
|
||||
|
||||
REGISTERED_VAULTS = parse_registered_vaults()
|
||||
DEFAULT_VAULT_NAME = next(iter(REGISTERED_VAULTS), VAULT_PATH.name or "vault")
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import json
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from app import auth
|
||||
from app.auth import require_auth
|
||||
from app.config import CLI_BIN, DEFAULT_TIMEOUT, HOST, MAX_TIMEOUT, PORT, VAULT_PATH
|
||||
from app.policy import authorize_command, vault_for_path
|
||||
from app.runner import execute_command
|
||||
|
||||
def error(status_code:int, detail:str) -> JSONResponse:
|
||||
return JSONResponse({"detail": detail}, status_code=status_code)
|
||||
|
||||
def parse_args(value:Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
raise ValueError("args must be a list of strings")
|
||||
return value
|
||||
|
||||
def parse_timeout(value:Any) -> float:
|
||||
if value is None:
|
||||
return DEFAULT_TIMEOUT
|
||||
if not isinstance(value, int | float) or value <= 0:
|
||||
raise ValueError("timeout must be a positive number")
|
||||
return min(float(value), MAX_TIMEOUT)
|
||||
|
||||
def safe_cwd(cwd:str|None) -> Path:
|
||||
path = Path(cwd).resolve() if cwd else VAULT_PATH
|
||||
if not path.exists() or not path.is_dir():
|
||||
raise ValueError(f"cwd does not exist or is not a directory: {path}")
|
||||
if vault_for_path(path) is None:
|
||||
raise ValueError(f"cwd must be inside a registered vault: {path}")
|
||||
return path
|
||||
|
||||
async def health(request:Request) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"vault": str(VAULT_PATH),
|
||||
"cli": CLI_BIN,
|
||||
"auth": bool(auth.JWT_SECRET),
|
||||
"jwt": bool(auth.JWT_SECRET),
|
||||
}
|
||||
)
|
||||
|
||||
async def run_command(request:Request) -> JSONResponse:
|
||||
auth_context = require_auth(request)
|
||||
if isinstance(auth_context, JSONResponse):
|
||||
return auth_context
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return error(400, "invalid JSON body")
|
||||
|
||||
try:
|
||||
if "command" in payload:
|
||||
raise ValueError("command strings are not allowed; use args as a list of strings")
|
||||
if "mode" in payload:
|
||||
raise ValueError("mode is not allowed; only the configured Obsidian CLI can be executed")
|
||||
|
||||
args = parse_args(payload.get("args"))
|
||||
stdin = payload.get("stdin")
|
||||
if stdin is not None and not isinstance(stdin, str):
|
||||
raise ValueError("stdin must be a string")
|
||||
|
||||
cwd_value = payload.get("cwd")
|
||||
if cwd_value is not None and not isinstance(cwd_value, str):
|
||||
raise ValueError("cwd must be a string")
|
||||
|
||||
cwd = safe_cwd(cwd_value)
|
||||
timeout = parse_timeout(payload.get("timeout"))
|
||||
authorize_command(auth_context.policy, args, cwd)
|
||||
except ValueError as exc:
|
||||
return error(400, str(exc))
|
||||
except PermissionError as exc:
|
||||
return error(403, str(exc))
|
||||
|
||||
return await execute_command(args=args, cwd=cwd, stdin=stdin, timeout=timeout)
|
||||
|
||||
async def typescript_sdk_example(request:Request) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"example": """
|
||||
import { ObsidianCliClient } from '@obsidian-api/sdk';
|
||||
|
||||
const obsidian = new ObsidianCliClient({
|
||||
baseUrl: 'http://localhost:8080',
|
||||
token: '<jwt>',
|
||||
});
|
||||
|
||||
await obsidian.create({
|
||||
path: 'Inbox/Hello.md',
|
||||
content: '# Hello from n8n/Prism',
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
const note = await obsidian.read({ path: 'Inbox/Hello.md' });
|
||||
console.log(note.stdout);
|
||||
""".strip()
|
||||
}
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
debug=False,
|
||||
routes=[
|
||||
Route("/health", health, methods=["GET"]),
|
||||
Route("/commands", run_command, methods=["POST"]),
|
||||
Route("/sdk/typescript", typescript_sdk_example, methods=["GET"]),
|
||||
],
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("app.main:app", host=HOST, port=PORT)
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenPolicy:
|
||||
token:str = ""
|
||||
name:str = "jwt"
|
||||
vaults:tuple[str, ...] = ("*",)
|
||||
paths:tuple[str, ...] = ("*",)
|
||||
commands:tuple[str, ...] = ("*",)
|
||||
|
||||
def allows_all_vaults(self) -> bool:
|
||||
return "*" in self.vaults
|
||||
|
||||
def allows_all_paths(self) -> bool:
|
||||
return "*" in self.paths
|
||||
|
||||
def allows_all_commands(self) -> bool:
|
||||
return "*" in self.commands
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandTarget:
|
||||
command:str|None
|
||||
vault:str|None
|
||||
paths:tuple[str, ...]
|
||||
|
||||
PATH_KEYS = {"path", "file", "folder", "name", "to"}
|
||||
PATH_REQUIRED_WHEN_RESTRICTED = {
|
||||
"append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff",
|
||||
"search", "search:context", "files", "folders",
|
||||
"property:set", "property:remove", "property:read", "properties",
|
||||
"aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task",
|
||||
}
|
||||
PATHLESS_ALLOWED_WHEN_RESTRICTED = {"help", "version"}
|
||||
|
||||
def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||
return tuple(value)
|
||||
raise ValueError("JWT claims vaults, paths and commands must be strings or string arrays")
|
||||
|
||||
def vault_for_path(path:Path) -> str|None:
|
||||
resolved = path.resolve()
|
||||
best_name:str|None = None
|
||||
best_len = -1
|
||||
|
||||
for name, vault_path in REGISTERED_VAULTS.items():
|
||||
root = Path(vault_path).resolve()
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
root_len = len(str(root))
|
||||
if root_len > best_len:
|
||||
best_name = name
|
||||
best_len = root_len
|
||||
|
||||
return best_name
|
||||
|
||||
def normalize_policy_path(value:str) -> str:
|
||||
normalized = value.strip().replace("\\", "/").lstrip("/")
|
||||
while "//" in normalized:
|
||||
normalized = normalized.replace("//", "/")
|
||||
return normalized
|
||||
|
||||
def is_path_allowed(path:str, allowed_patterns:tuple[str, ...]) -> bool:
|
||||
normalized = normalize_policy_path(path)
|
||||
|
||||
for pattern in allowed_patterns:
|
||||
pattern_normalized = normalize_policy_path(pattern)
|
||||
if pattern_normalized == "*":
|
||||
return True
|
||||
if pattern_normalized.endswith("/**"):
|
||||
prefix = pattern_normalized[:-3].rstrip("/")
|
||||
if normalized == prefix or normalized.startswith(prefix + "/"):
|
||||
return True
|
||||
elif pattern_normalized.endswith("/"):
|
||||
if normalized.startswith(pattern_normalized):
|
||||
return True
|
||||
elif normalized == pattern_normalized or normalized.startswith(pattern_normalized.rstrip("/") + "/"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def parse_kv_arg(arg:str) -> tuple[str, str]|None:
|
||||
if "=" not in arg:
|
||||
return None
|
||||
key, value = arg.split("=", 1)
|
||||
if not key:
|
||||
return None
|
||||
return key, value
|
||||
|
||||
def extract_command_target(args:list[str]) -> CommandTarget:
|
||||
vault:str|None = None
|
||||
command:str|None = None
|
||||
paths:list[str] = []
|
||||
|
||||
for arg in args:
|
||||
parsed = parse_kv_arg(arg)
|
||||
if parsed and parsed[0] == "vault" and command is None:
|
||||
vault = parsed[1]
|
||||
continue
|
||||
command = arg
|
||||
break
|
||||
|
||||
for arg in args:
|
||||
parsed = parse_kv_arg(arg)
|
||||
if not parsed:
|
||||
continue
|
||||
key, value = parsed
|
||||
if key in PATH_KEYS and value:
|
||||
paths.append(value)
|
||||
|
||||
return CommandTarget(command=command, vault=vault, paths=tuple(paths))
|
||||
|
||||
def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None:
|
||||
if policy is None:
|
||||
return
|
||||
|
||||
target = extract_command_target(args)
|
||||
command = target.command or ""
|
||||
|
||||
if not policy.allows_all_commands() and command not in policy.commands:
|
||||
raise PermissionError(f"token is not allowed to run command: {command}")
|
||||
|
||||
if not policy.allows_all_vaults():
|
||||
vault = target.vault or vault_for_path(cwd) or DEFAULT_VAULT_NAME
|
||||
if vault not in policy.vaults:
|
||||
raise PermissionError(f"token is not allowed to access vault: {vault}")
|
||||
|
||||
if policy.allows_all_paths():
|
||||
return
|
||||
|
||||
if not target.paths:
|
||||
if command in PATHLESS_ALLOWED_WHEN_RESTRICTED:
|
||||
return
|
||||
if command in PATH_REQUIRED_WHEN_RESTRICTED:
|
||||
raise PermissionError("token has path restrictions; command must include an allowed path/file/folder/name argument")
|
||||
raise PermissionError(f"token has path restrictions; command is not allowed without an explicit path: {command}")
|
||||
|
||||
denied = [path for path in target.paths if not is_path_allowed(path, policy.paths)]
|
||||
if denied:
|
||||
raise PermissionError(f"token is not allowed to access path: {denied[0]}")
|
||||
@@ -0,0 +1,58 @@
|
||||
import asyncio, uuid, time
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.config import CLI_BIN
|
||||
|
||||
def error(status_code:int, detail:str) -> JSONResponse:
|
||||
return JSONResponse({"detail": detail}, status_code=status_code)
|
||||
|
||||
async def execute_command(args:list[str], cwd:Path, stdin:str|None, timeout:float) -> JSONResponse:
|
||||
command_id = str(uuid.uuid4())
|
||||
command = [CLI_BIN, *args]
|
||||
started = time.perf_counter()
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
cwd=str(cwd),
|
||||
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return error(500, f"configured CLI binary was not found: {CLI_BIN}")
|
||||
|
||||
try:
|
||||
stdout_b, stderr_b = await asyncio.wait_for(
|
||||
process.communicate(None if stdin is None else stdin.encode()),
|
||||
timeout=timeout,
|
||||
)
|
||||
timed_out = False
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
stdout_b, stderr_b = await process.communicate()
|
||||
timed_out = True
|
||||
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
stdout = stdout_b.decode(errors="replace")
|
||||
stderr = stderr_b.decode(errors="replace")
|
||||
exit_code = process.returncode if process.returncode is not None else -1
|
||||
|
||||
if timed_out:
|
||||
exit_code = 124
|
||||
stderr = (stderr + f"\nCommand timed out after {timeout}s").strip()
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": command_id,
|
||||
"command": command,
|
||||
"cwd": str(cwd),
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"duration_ms": duration_ms,
|
||||
"timed_out": timed_out,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user