···2020# init the db with dev presets
2121RUN python3 -m millipds init millipds.test --dev
22222323+# create a test user
2424+RUN python3 -m millipds account create bob.test did:web:bob.test --unsafe_password=hunter2
2525+2326# do the thing
2427CMD python3 -m millipds run --listen_host=0.0.0.0 --listen_port=8123
2528
+41-2
src/millipds/__main__.py
···33Usage:
44 millipds init <hostname> [--dev|--sandbox]
55 millipds config [--pds_pfx=URL] [--pds_did=DID] [--bsky_appview_pfx=URL] [--bsky_appview_did=DID]
66+ millipds account create <did> <handle> [--unsafe_password=PW]
67 millipds run [--sock_path=PATH] [--listen_host=HOST] [--listen_port=PORT]
78 millipds (-h | --help)
89 millipds --version
···1920 Any options not specified will be left at their previous values. Once changes
2021 have been made (or even if they haven't), the new config will be printed.
21222323+ Do not change the config while the PDS is running (TODO: enforce this in code (or make sure it's harmless?))
2424+2225 --pds_pfx=URL The HTTP URL prefix that this PDS is publicly accessible at (e.g. mypds.example)
2326 --pds_did=DID This PDS's DID (e.g. did:web:mypds.example)
2427 --bsky_appview_pfx=URL AppView URL prefix e.g. "https://api.bsky-sandbox.dev"
2528 --bsky_appview_did=DID AppView DID e.g. did:web:api.bsky-sandbox.dev
26293030+Account create:
3131+ Create a new user account on the PDS. Bring your own DID and corresponding
3232+ handle - millipds will not (yet?) attempt to validate either.
3333+ You'll be prompted for a password interactively.
3434+3535+ TODO: consider bring-your-own signing key?
3636+3737+ --unsafe_password=PW Specify password non-iteractively, for use in test scripts etc.
3838+2739Run:
2840 Launch the service (in the foreground)
2941···3648 --version Show version.
3749"""
38503939-from docopt import docopt
4051import importlib.metadata
4152import asyncio
5353+import sys
5454+import logging
5555+from getpass import getpass
5656+5757+from docopt import docopt
42584359from . import service
4460from . import database
6161+from . import crypto
6262+6363+6464+logging.basicConfig(level=logging.DEBUG) # TODO: make this configurable?
6565+45664667"""
4768This is the entrypoint for the `millipds` command (declared in project.scripts)
···7596 bsky_appview_pfx="https://api.bsky-sandbox.dev",
7697 bsky_appview_did="did:web:api.bsky-sandbox.dev",
7798 )
7878- else:
9999+ else: # "prod" presets
79100 db.update_config(
80101 pds_pfx=f'https://{args["<hostname>"]}',
81102 pds_did=f'did:web:{args["<hostname>"]}',
···98119 bsky_appview_did=args["--bsky_appview_did"],
99120 )
100121 db.print_config()
122122+ elif args["account"]:
123123+ if args["create"]:
124124+ pw = args["--unsafe_password"]
125125+ if pw:
126126+ # rationale: only allow non-iteractive password input from scripts etc.
127127+ if sys.stdin.buffer.isatty():
128128+ print("error: --unsafe_password can't be used from an interactive shell")
129129+ return
130130+ else:
131131+ pw = getpass(f"Password for new account: ")
132132+ db.account_create(
133133+ did=args["<did>"],
134134+ handle=args["<handle>"],
135135+ password=pw,
136136+ privkey=crypto.keygen_p256() # TODO: supply from arg
137137+ )
138138+ else:
139139+ print("CLI arg parse error?!")
101140 elif args["run"]:
102141 asyncio.run(service.run(
103142 sock_path=args["--sock_path"],
···11"""
22-Ideally, all SQL statements are contained within this file
22+Ideally, all SQL statements are contained within this file.
33+44+Password hashing also happens in here, because it doesn't make much sense to do
55+it anywhere else.
36"""
4758from typing import Optional, Dict
69from functools import cached_property
710import secrets
1111+import os
1212+import logging
8131414+from argon2 import PasswordHasher # maybe this should come from .crypto?
915import apsw
1616+import apsw.bestpractice
1717+1818+from atmst.blockstore import BlockStore
10191120from . import static_config
1221from . import util
2222+from . import crypto
2323+2424+logger = logging.getLogger(__name__)
2525+2626+# https://rogerbinns.github.io/apsw/bestpractice.html
2727+apsw.bestpractice.apply(apsw.bestpractice.recommended)
13281429class Database:
1530 def __init__(self, path: str=static_config.MAIN_DB_PATH) -> None:
1631 util.mkdirs_for_file(path)
1732 self.con = apsw.Connection(path)
3333+ self.pw_hasher = PasswordHasher()
18341935 try:
2036 if self.config["db_version"] != static_config.MILLIPDS_DB_VERSION:
···2743 self._init_central_tables()
28442945 def _init_central_tables(self):
4646+ logger.info("initing central tables")
3047 self.con.execute(
3148 """
3249 CREATE TABLE config(
···42594360 self.con.execute(
4461 """
4545- INSERT INTO config (
6262+ INSERT INTO config(
4663 db_version,
4764 jwt_access_secret
4865 ) VALUES (?, ?)
···5471 """
5572 CREATE TABLE user(
5673 did TEXT PRIMARY KEY NOT NULL,
7474+ handle TEXT NOT NULL,
5775 prefs BLOB NOT NULL,
5876 pw_hash TEXT NOT NULL,
5977 repo_path TEXT NOT NULL,
···6179 )
6280 """
6381 )
8282+8383+ self.con.execute("CREATE UNIQUE INDEX user_by_handle ON user(handle)")
64846585 self.con.execute(
6686 """
···84104 if pds_did is not None:
85105 self.con.execute("UPDATE config SET pds_did=?", (pds_did,))
86106 if bsky_appview_pfx is not None:
8787- self.con.execute("UPDATE config SET bsky_appview_pfx=?", (bsky_appview_pfx,))
107107+ self.con.execute(
108108+ "UPDATE config SET bsky_appview_pfx=?",
109109+ (bsky_appview_pfx,)
110110+ )
88111 if bsky_appview_did is not None:
8989- self.con.execute("UPDATE config SET bsky_appview_did=?", (bsky_appview_did,))
112112+ self.con.execute(
113113+ "UPDATE config SET bsky_appview_did=?",
114114+ (bsky_appview_did,)
115115+ )
901169191- del self.config # invalidate the cached value
117117+ try:
118118+ del self.config # invalidate the cached value
119119+ except AttributeError:
120120+ pass
9212193122 @cached_property
94123 def config(self) -> Dict[str, object]:
···117146 if redact_secrets and "secret" in k:
118147 v = "[REDACTED]"
119148 print(f"{k:<{maxlen}} : {v!r}")
149149+150150+ def account_create(self,
151151+ did: str,
152152+ handle: str,
153153+ password: str,
154154+ privkey: crypto.ec.EllipticCurvePrivateKey
155155+ ) -> None:
156156+ pw_hash = self.pw_hasher.hash(password)
157157+ privkey_pem = crypto.privkey_to_pem(privkey)
158158+ repo_path = f"{static_config.REPOS_DIR}/{util.did_to_safe_filename(did)}.sqlite3"
159159+ logger.info(
160160+ f"creating account for did={did}, handle={handle} at {repo_path}"
161161+ )
162162+ with self.con:
163163+ self.con.execute(
164164+ """
165165+ INSERT INTO user(
166166+ did,
167167+ handle,
168168+ prefs,
169169+ pw_hash,
170170+ repo_path,
171171+ signing_key
172172+ ) VALUES (?, ?, ?, ?, ?, ?)
173173+ """,
174174+ (did, handle, b"{}", pw_hash, repo_path, privkey_pem)
175175+ )
176176+ UserDatabase.init_tables(self.con, did, repo_path)
177177+ self.con.execute("DETACH spoke")
178178+179179+180180+class UserDBBlockStore(BlockStore):
181181+ pass # TODO
182182+183183+184184+class UserDatabase:
185185+ def __init__(self, wcon: apsw.Connection, did: str, path: str) -> None:
186186+ self.wcon = wcon # writes go via the hub database connection, using ATTACH
187187+ self.rcon = apsw.Connection(path, flags=apsw.SQLITE_OPEN_READONLY)
188188+189189+ # TODO: check db version and did match
190190+191191+ @staticmethod
192192+ def init_tables(wcon: apsw.Connection, did: str, path: str) -> None:
193193+ util.mkdirs_for_file(path)
194194+ wcon.execute("ATTACH ? AS spoke", (path,))
195195+196196+ wcon.execute(
197197+ """
198198+ CREATE TABLE spoke.repo(
199199+ db_version INTEGER NOT NULL,
200200+ did TEXT NOT NULL
201201+ )
202202+ """
203203+ )
204204+205205+ wcon.execute(
206206+ "INSERT INTO spoke.repo(db_version, did) VALUES (?, ?)",
207207+ (static_config.MILLIPDS_DB_VERSION, did)
208208+ )
209209+210210+ # TODO: the other tables
211211+212212+ # nb: caller is responsible for running "DETACH spoke", after the end
213213+ # of the transaction
+1-3
src/millipds/service.py
···991010from . import static_config
11111212-logging.basicConfig(level=logging.DEBUG) # TODO: make this configurable?
1313-14121513async def hello(request: web.Request):
1614 version = importlib.metadata.version("millipds")
···4644This gets invoked via millipds.__main__.py
4745"""
4846async def run(sock_path: Optional[str], host: str, port: int):
4949- runner = web.AppRunner(app, access_log_format=static_config.LOG_FMT)
4747+ runner = web.AppRunner(app, access_log_format=static_config.HTTP_LOG_FMT)
5048 await runner.setup()
51495250 if sock_path is None:
+4-4
src/millipds/static_config.py
···44(some of this stuff might want to be broken out into a proper config file, eventually)
55"""
6677-LOG_FMT = '%{X-Forwarded-For}i %t (%Tf) "%r" %s %b "%{Referer}i" "%{User-Agent}i"'
77+HTTP_LOG_FMT = '%{X-Forwarded-For}i %t (%Tf) "%r" %s %b "%{Referer}i" "%{User-Agent}i"'
8899GROUPNAME = "millipds-sock"
10101111MILLIPDS_DB_VERSION = 1 # this gets bumped if we make breaking changes to the db schema
12121313-DATA_DIR = "./data/"
1414-MAIN_DB_PATH = DATA_DIR + "millipds.sqlite3"
1515-REPOS_DIR = DATA_DIR + "repos/"
1313+DATA_DIR = "./data"
1414+MAIN_DB_PATH = DATA_DIR + "/millipds.sqlite3"
1515+REPOS_DIR = DATA_DIR + "/repos"
+16
src/millipds/util.py
···11import os
22+import hashlib
2334def mkdirs_for_file(path: str) -> None:
45 os.makedirs(os.path.dirname(path), exist_ok=True)
66+77+FILANEME_SAFE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
88+99+def did_to_safe_filename(did: str) -> str:
1010+ """
1111+ The format is <sha256(did)>_<filtered_did>
1212+ The former guarantees uniqueness, and the latter makes it human-recognizeable (ish)
1313+ """
1414+1515+ hexdigest = hashlib.sha256(did.encode()).hexdigest()
1616+ filtered = "".join(char for char in did if char in FILANEME_SAFE_CHARS)
1717+1818+ # Truncate to make sure we're staying within PATH_MAX
1919+ # (with room to spare, in case the caller appends a file extension)
2020+ return f"{hexdigest}_{filtered}"[:200]