From ac3231d2069bf740aeaa92ead8c0dc325219e967 Mon Sep 17 00:00:00 2001 From: Zicklag Date: Fri, 8 Nov 2024 16:11:17 -0600 Subject: [PATCH] feat: add username claim and delete logic to redis and update DNS server with it. --- .typos.toml | 1 + package.json | 10 +-- src/lib/dns/server.ts | 10 ++- src/lib/redis.ts | 43 +++++++++++- src/lib/usernames.ts | 68 +++++++++++++++++++ src/lib/utils/username.ts | 2 + .../__internal__/admin/+page.svelte | 1 + .../admin/usernames/+page.server.ts | 33 +++++++++ .../__internal__/admin/usernames/+page.svelte | 37 ++++++++++ 9 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 src/lib/usernames.ts create mode 100644 src/routes/(internal)/__internal__/admin/usernames/+page.server.ts create mode 100644 src/routes/(internal)/__internal__/admin/usernames/+page.svelte diff --git a/.typos.toml b/.typos.toml index b40b14e4..8e67bbec 100644 --- a/.typos.toml +++ b/.typos.toml @@ -3,3 +3,4 @@ extend-exclude = ["src/lib/pow/wasm/*"] [default.extend-words] ratatui = "ratatui" +additionals = "additionals" diff --git a/package.json b/package.json index 41d3f9a5..6c01241e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "@types/cookie": "^0.6.0", + "@types/dns-packet": "^5.6.5", "@types/eslint": "^8.56.12", "@types/node": "^20.16.11", "@types/nodemailer": "^6.4.16", @@ -38,7 +39,9 @@ "codemirror": "^6.0.1", "cookie": "^0.6.0", "date-fns": "^3.6.0", + "dinodns": "^0.0.9", "discord.js": "^14.16.3", + "dns-packet": "^5.6.1", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.44.1", @@ -63,6 +66,7 @@ "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.34.3", "proxy-agent": "^6.4.0", + "redis": "^4.7.0", "sanitize-html": "^2.13.1", "slugify": "^1.6.6", "svelte": "5.0.0-next.239", @@ -75,11 +79,7 @@ "vite-plugin-top-level-await": "^1.4.4", "vite-plugin-wasm": "^3.3.0", "ws": "^8.18.0", - "zod": "^3.23.8", - "@types/dns-packet": "^5.6.5", - "dinodns": "^0.0.9", - "dns-packet": "^5.6.1", - "redis": "^4.7.0" + "zod": "^3.23.8" }, "dependencies": { "@atproto/api": "^0.13.12", diff --git a/src/lib/dns/server.ts b/src/lib/dns/server.ts index 1fa63781..30f5849c 100644 --- a/src/lib/dns/server.ts +++ b/src/lib/dns/server.ts @@ -15,7 +15,7 @@ import { z } from 'zod'; import { RCode } from 'dinodns/common/core/utils'; import { redis } from '$lib/redis'; -const REDIS_USER_PREFIX = 'weird:users:'; +const REDIS_USER_PREFIX = 'weird:users:names:'; const REDIS_DNS_RECORD_PREFIX = 'weird:dns:records:'; const redisDnsRecordSchema = z.array( @@ -100,6 +100,12 @@ export async function startDnsServer() { ] }); + // TODO: handle AXFR requests so we can setup a secondary DNS server. + + // TODO: add glue records for nameservers ( I think ): + // + // https://serverfault.com/questions/309622/what-is-a-glue-record + // Set all answers to authoritative by default s.use(async (_req, res, next) => { if (res.finished) return next(); @@ -312,7 +318,7 @@ export async function startDnsServer() { case 'TXT': const txtUsername = name.match(WEIRD_HOST_TXT_RECORD_REGEX)?.[1]; if (!txtUsername) return returnAnswers(null); - const pubkey = await redis.get(REDIS_USER_PREFIX + txtUsername); + const pubkey = await redis.hGet(REDIS_USER_PREFIX + txtUsername, 'subspace'); if (!pubkey) return returnAnswers(null); returnAnswers([ { diff --git a/src/lib/redis.ts b/src/lib/redis.ts index 024f78ef..a063c99e 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -2,6 +2,47 @@ import { env } from '$env/dynamic/private'; import { createClient } from 'redis'; /** The global redis client used by the Weird server. */ -export const redis = await createClient({ url: env.REDIS_URL }) +export const redis = await createClient({ + url: env.REDIS_URL + // EXPERIMENTAL: Currently using transactions, aka MULTI, instead. + // scripts: { + // /** This is meant to claim a new weird username and associate 3 things to each-other: + // * rauthy user ID, iroh namespace pubkey, the actual username */ + // claimUsername: defineScript({ + // SCRIPT: ` + // local usernameKey = KEYS[1] + // local rauthyIdKey = KEYS[2] + // local subspaceKey = KEYS[3] + // local username = ARGV[1] + // local rauthyId = ARGV[2] + // local subspace = ARGV[3] + + // if redis.call("EXISTS", usernameKey) > 0 then + // return redis.error_reply(string.format("USER_EXISTS The username '%s' already exists so it cannot be claimed.", username)) + // end + + // redis.call("HSET", usernameKey, "subspace", subspace) + // redis.call("HSET", usernameKey, "rauthyId", rauthyId) + // redis.call("HSET", rauthyIdKey, "username", username) + // redis.call("HSET", rauthyIdKey, "subspace", subspace) + // redis.call("HSET", subspaceKey, "username", username) + // redis.call("HSET", subspaceKey, "rauthyId", rauthyId) + // redis.call("HSET", "testing", "dummy", "something") + // `, + // NUMBER_OF_KEYS: 3, + // FIRST_KEY_INDEX: 1, + // transformArguments(username: string, rauthyId: string, subspace: string) { + // return [ + // 'weird:users:names:' + username, + // 'weird:users:rauthyIds:' + rauthyId, + // 'weird:users:subspaces:' + subspace, + // username, + // rauthyId, + // subspace + // ]; + // } + // }) + // } +}) .on('error', (err) => console.error('Redis client error', err)) .connect(); diff --git a/src/lib/usernames.ts b/src/lib/usernames.ts new file mode 100644 index 00000000..24c4bcac --- /dev/null +++ b/src/lib/usernames.ts @@ -0,0 +1,68 @@ +import { redis } from '$lib/redis'; +import { validUsernameRegex } from './utils/username'; + +const USER_NAMES_PREFIX = 'weird:users:names:'; +const USER_RAUTHY_IDS_PREFIX = 'weird:users:rauthyIds:'; +const USER_SUBSPACES_PREFIX = 'weird:users:subspaces:'; + +export async function claimUsername(username: string, rauthyId: string, subspace: string) { + if (!username.match(validUsernameRegex)) { + throw 'Username does not pass valid username check.'; + } + + const usernameKey = USER_NAMES_PREFIX + username; + const rauthyIdKey = USER_RAUTHY_IDS_PREFIX + rauthyId; + const subspaceKey = USER_SUBSPACES_PREFIX + subspace; + + const TRIES = 3; + const failures = 0; + while (failures <= TRIES) { + redis.watch([usernameKey, rauthyIdKey, subspaceKey]); + + if (await redis.exists(usernameKey)) { + await redis.unwatch(); + throw `Cannot claim username "${username}": username already claimed.`; + } + + const multi = redis.multi(); + + multi.del(usernameKey); + multi.hSet(usernameKey, 'subspace', subspace); + multi.hSet(usernameKey, 'rauthyId', rauthyId); + multi.del(rauthyIdKey); + multi.hSet(rauthyIdKey, 'username', username); + multi.hSet(rauthyIdKey, 'subspace', subspace); + multi.del(subspaceKey); + multi.hSet(subspaceKey, 'username', username); + multi.hSet(subspaceKey, 'rauthyId', rauthyId); + + try { + await multi.exec(); + return; + } catch (e) { + console.warn( + `Initial attempt to claim username ${username} failed will try ${TRIES - failures} more times: ${e}` + ); + } + } +} + +export async function deleteUsername(username: string) { + const usernameKey = USER_NAMES_PREFIX + username; + + await redis.watch(usernameKey); + + const user = await redis.hGetAll(usernameKey); + + const multi = redis.multi(); + + await multi.del(usernameKey); + if (user.subspace) { + await multi.del(USER_SUBSPACES_PREFIX + user.subspace); + } + if (user.rauthyId) { + await multi.del(USER_RAUTHY_IDS_PREFIX + user.rauthyId); + } + + await multi.exec(); +} diff --git a/src/lib/utils/username.ts b/src/lib/utils/username.ts index d0be46b8..080f5bfd 100644 --- a/src/lib/utils/username.ts +++ b/src/lib/utils/username.ts @@ -1,5 +1,7 @@ import { env } from '$env/dynamic/public'; +export const validUsernameRegex = /^([a-z0-9][_-]?){3,32}$/; + export interface Username { name: string; domain?: string; diff --git a/src/routes/(internal)/__internal__/admin/+page.svelte b/src/routes/(internal)/__internal__/admin/+page.svelte index 5cfb5e08..046c90f6 100644 --- a/src/routes/(internal)/__internal__/admin/+page.svelte +++ b/src/routes/(internal)/__internal__/admin/+page.svelte @@ -5,4 +5,5 @@
  • Data Explorer
  • DNS Resolver
  • DNS Record Set
  • +
  • Username Manager
  • diff --git a/src/routes/(internal)/__internal__/admin/usernames/+page.server.ts b/src/routes/(internal)/__internal__/admin/usernames/+page.server.ts new file mode 100644 index 00000000..d3de9e2d --- /dev/null +++ b/src/routes/(internal)/__internal__/admin/usernames/+page.server.ts @@ -0,0 +1,33 @@ +import { claimUsername, deleteUsername } from '$lib/usernames'; +import type { Actions } from './$types'; + +export const actions = { + claimUsername: async ({ request }) => { + const formData = await request.formData(); + const username = formData.get('username')?.toString(); + const rauthyId = formData.get('rauthyId')?.toString(); + const subspace = formData.get('subspace')?.toString(); + if (!(username && rauthyId && subspace)) return { error: 'You must fill in all fields' }; + + try { + await claimUsername(username, rauthyId, subspace); + } catch (e) { + return { error: `Error claiming username: ${e}` }; + } + + return { success: `Username ${username} claimed.` }; + }, + deleteUsername: async ({ request }) => { + const formData = await request.formData(); + const username = formData.get('username')?.toString(); + if (!username) return { error: 'You must fill in all fields' }; + + try { + await deleteUsername(username); + } catch (e) { + return { error: `Error deleting username: ${e}` }; + } + + return { success: `Username ${username} deleted.` }; + } +} satisfies Actions; diff --git a/src/routes/(internal)/__internal__/admin/usernames/+page.svelte b/src/routes/(internal)/__internal__/admin/usernames/+page.svelte new file mode 100644 index 00000000..b3dc4aaa --- /dev/null +++ b/src/routes/(internal)/__internal__/admin/usernames/+page.svelte @@ -0,0 +1,37 @@ + + +{#if form?.error} +
    + {form.error} +
    +{:else if form?.success} +
    + {form.success} +
    +{/if} + +

    Claim Username

    + +
    + +
    + + + + +
    +
    + +

    Delete Username

    + +
    + +
    + + +
    +