serverless-ircd 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +17 -4
- package/apps/cf-worker/package.json +2 -2
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +86 -9
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +40 -1
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +393 -20
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +8 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +395 -36
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +544 -7
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/scripts/package.json +1 -1
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +17 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import {
|
|
3
3
|
CHANSERV_NICK,
|
|
4
4
|
DEFAULT_CHANNEL_LEVELS,
|
|
5
|
+
FOUNDER_RANK,
|
|
5
6
|
SHORTHAND_LEVELS,
|
|
6
7
|
chanservReducer,
|
|
7
8
|
positiveMlockLetters,
|
|
9
|
+
rankOf,
|
|
8
10
|
resolveMembershipTarget,
|
|
9
11
|
} from '../../src/commands/chanserv';
|
|
10
12
|
import { Effect } from '../../src/effects';
|
|
@@ -1670,6 +1672,29 @@ describe('SHORTHAND_LEVELS / DEFAULT_CHANNEL_LEVELS constants', () => {
|
|
|
1670
1672
|
});
|
|
1671
1673
|
});
|
|
1672
1674
|
|
|
1675
|
+
describe('rankOf / FOUNDER_RANK', () => {
|
|
1676
|
+
it('maps positive access levels to themselves', () => {
|
|
1677
|
+
expect(rankOf(SHORTHAND_LEVELS.VOP)).toBe(SHORTHAND_LEVELS.VOP);
|
|
1678
|
+
expect(rankOf(SHORTHAND_LEVELS.SOP)).toBe(SHORTHAND_LEVELS.SOP);
|
|
1679
|
+
expect(rankOf(7)).toBe(7);
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
it('clamps zero and negative levels to the minimum rank', () => {
|
|
1683
|
+
expect(rankOf(0)).toBe(0);
|
|
1684
|
+
expect(rankOf(-3)).toBe(0);
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
it('passes the founder rank through unchanged', () => {
|
|
1688
|
+
expect(rankOf(FOUNDER_RANK)).toBe(Number.POSITIVE_INFINITY);
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
it('FOUNDER_RANK outranks every shorthand access level', () => {
|
|
1692
|
+
for (const level of Object.values(SHORTHAND_LEVELS)) {
|
|
1693
|
+
expect(FOUNDER_RANK).toBeGreaterThan(level);
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1673
1698
|
// ============================================================================
|
|
1674
1699
|
// OP / DEOP / VOICE / DEVOICE
|
|
1675
1700
|
// ============================================================================
|
|
@@ -1895,6 +1920,109 @@ describe('chanservReducer — DEOP', () => {
|
|
|
1895
1920
|
':ChanServ!ChanServ@services NOTICE mallory :Permission denied.',
|
|
1896
1921
|
);
|
|
1897
1922
|
});
|
|
1923
|
+
|
|
1924
|
+
it('rejects DEOP of the channel founder by an AUTOOP-level access entry', () => {
|
|
1925
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
1926
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1927
|
+
services.registerNick('carol', 'pw', 'c@example.com');
|
|
1928
|
+
services.registerChannel('#home', 'alice');
|
|
1929
|
+
services.setChannelAccess('#home', 'carol', DEFAULT_CHANNEL_LEVELS.AUTOOP);
|
|
1930
|
+
const conn = makeConn('c1', 'carol');
|
|
1931
|
+
conn.account = 'carol';
|
|
1932
|
+
conn.userModes.registered = true;
|
|
1933
|
+
const ctx = makeCtx(conn, services);
|
|
1934
|
+
|
|
1935
|
+
const out = chanservReducer(conn, privmsg('DEOP #home alice'), ctx);
|
|
1936
|
+
|
|
1937
|
+
expect(firstBroadcast(out.effects)).toBeUndefined();
|
|
1938
|
+
expect(firstApplyChannelDelta(out.effects)).toBeUndefined();
|
|
1939
|
+
expect(sentTexts(out.effects)).toContain(
|
|
1940
|
+
':ChanServ!ChanServ@services NOTICE carol :You do not have sufficient privileges on #home to affect alice.',
|
|
1941
|
+
);
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
it('allows peer-level DEOP between AUTOOP-level access entries', () => {
|
|
1945
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
1946
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1947
|
+
services.registerNick('carol', 'pw', 'c@example.com');
|
|
1948
|
+
services.registerNick('dave', 'pw', 'd@example.com');
|
|
1949
|
+
services.registerChannel('#home', 'alice');
|
|
1950
|
+
services.setChannelAccess('#home', 'carol', DEFAULT_CHANNEL_LEVELS.AUTOOP);
|
|
1951
|
+
services.setChannelAccess('#home', 'dave', DEFAULT_CHANNEL_LEVELS.AUTOOP);
|
|
1952
|
+
const conn = makeConn('c1', 'carol');
|
|
1953
|
+
conn.account = 'carol';
|
|
1954
|
+
conn.userModes.registered = true;
|
|
1955
|
+
const ctx = makeCtx(conn, services);
|
|
1956
|
+
|
|
1957
|
+
const out = chanservReducer(conn, privmsg('DEOP #home dave'), ctx);
|
|
1958
|
+
|
|
1959
|
+
expect(firstBroadcast(out.effects)).toBeDefined();
|
|
1960
|
+
expect(firstApplyChannelDelta(out.effects)).toBeDefined();
|
|
1961
|
+
expect(sentTexts(out.effects)).toContain(
|
|
1962
|
+
':ChanServ!ChanServ@services NOTICE carol :Set mode -o on dave on #home.',
|
|
1963
|
+
);
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
it('rejects DEOP of a higher-ranked access entry by a lower-ranked caller', () => {
|
|
1967
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
1968
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1969
|
+
services.registerNick('carol', 'pw', 'c@example.com');
|
|
1970
|
+
services.registerNick('dave', 'pw', 'd@example.com');
|
|
1971
|
+
services.registerChannel('#home', 'alice');
|
|
1972
|
+
services.setChannelAccess('#home', 'carol', DEFAULT_CHANNEL_LEVELS.AUTOOP);
|
|
1973
|
+
services.setChannelAccess('#home', 'dave', SHORTHAND_LEVELS.SOP);
|
|
1974
|
+
const conn = makeConn('c1', 'carol');
|
|
1975
|
+
conn.account = 'carol';
|
|
1976
|
+
conn.userModes.registered = true;
|
|
1977
|
+
const ctx = makeCtx(conn, services);
|
|
1978
|
+
|
|
1979
|
+
const out = chanservReducer(conn, privmsg('DEOP #home dave'), ctx);
|
|
1980
|
+
|
|
1981
|
+
expect(firstBroadcast(out.effects)).toBeUndefined();
|
|
1982
|
+
expect(firstApplyChannelDelta(out.effects)).toBeUndefined();
|
|
1983
|
+
expect(sentTexts(out.effects)).toContain(
|
|
1984
|
+
':ChanServ!ChanServ@services NOTICE carol :You do not have sufficient privileges on #home to affect dave.',
|
|
1985
|
+
);
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
it('allows an SOP-level entry to DEOP an AOP-level target', () => {
|
|
1989
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
1990
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
1991
|
+
services.registerNick('carol', 'pw', 'c@example.com');
|
|
1992
|
+
services.registerNick('dave', 'pw', 'd@example.com');
|
|
1993
|
+
services.registerChannel('#home', 'alice');
|
|
1994
|
+
services.setChannelAccess('#home', 'carol', SHORTHAND_LEVELS.SOP);
|
|
1995
|
+
services.setChannelAccess('#home', 'dave', DEFAULT_CHANNEL_LEVELS.AUTOOP);
|
|
1996
|
+
const conn = makeConn('c1', 'carol');
|
|
1997
|
+
conn.account = 'carol';
|
|
1998
|
+
conn.userModes.registered = true;
|
|
1999
|
+
const ctx = makeCtx(conn, services);
|
|
2000
|
+
|
|
2001
|
+
const out = chanservReducer(conn, privmsg('DEOP #home dave'), ctx);
|
|
2002
|
+
|
|
2003
|
+
expect(firstBroadcast(out.effects)).toBeDefined();
|
|
2004
|
+
expect(sentTexts(out.effects)).toContain(
|
|
2005
|
+
':ChanServ!ChanServ@services NOTICE carol :Set mode -o on dave on #home.',
|
|
2006
|
+
);
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
it('allows the founder to DEOP themselves', () => {
|
|
2010
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
2011
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2012
|
+
services.registerChannel('#home', 'alice');
|
|
2013
|
+
const conn = makeConn();
|
|
2014
|
+
conn.account = 'alice';
|
|
2015
|
+
conn.userModes.registered = true;
|
|
2016
|
+
const ctx = makeCtx(conn, services);
|
|
2017
|
+
|
|
2018
|
+
const out = chanservReducer(conn, privmsg('DEOP #home alice'), ctx);
|
|
2019
|
+
|
|
2020
|
+
expect(firstBroadcast(out.effects)).toBeDefined();
|
|
2021
|
+
expect(firstApplyChannelDelta(out.effects)).toBeDefined();
|
|
2022
|
+
expect(sentTexts(out.effects)).toContain(
|
|
2023
|
+
':ChanServ!ChanServ@services NOTICE alice :Set mode -o on alice on #home.',
|
|
2024
|
+
);
|
|
2025
|
+
});
|
|
1898
2026
|
});
|
|
1899
2027
|
|
|
1900
2028
|
describe('chanservReducer — VOICE', () => {
|
|
@@ -2076,6 +2204,44 @@ describe('chanservReducer — KICK', () => {
|
|
|
2076
2204
|
':ChanServ!ChanServ@services NOTICE alice :Syntax: KICK <#channel> <nick> [:<reason>].',
|
|
2077
2205
|
);
|
|
2078
2206
|
});
|
|
2207
|
+
|
|
2208
|
+
it('rejects KICK of the channel founder by an AUTOOP-level caller', () => {
|
|
2209
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
2210
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2211
|
+
services.registerNick('carol', 'pw', 'c@example.com');
|
|
2212
|
+
services.registerChannel('#home', 'alice');
|
|
2213
|
+
services.setChannelAccess('#home', 'carol', DEFAULT_CHANNEL_LEVELS.AUTOOP);
|
|
2214
|
+
const conn = makeConn('c1', 'carol');
|
|
2215
|
+
conn.account = 'carol';
|
|
2216
|
+
conn.userModes.registered = true;
|
|
2217
|
+
const ctx = makeCtx(conn, services);
|
|
2218
|
+
|
|
2219
|
+
const out = chanservReducer(conn, privmsg('KICK #home alice'), ctx);
|
|
2220
|
+
|
|
2221
|
+
expect(firstBroadcast(out.effects)).toBeUndefined();
|
|
2222
|
+
expect(firstApplyChannelDelta(out.effects)).toBeUndefined();
|
|
2223
|
+
expect(sentTexts(out.effects)).toContain(
|
|
2224
|
+
':ChanServ!ChanServ@services NOTICE carol :Permission denied.',
|
|
2225
|
+
);
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
it('allows the founder to KICK themselves (self-target through the rank guard)', () => {
|
|
2229
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
2230
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
2231
|
+
services.registerChannel('#home', 'alice');
|
|
2232
|
+
const conn = makeConn();
|
|
2233
|
+
conn.account = 'alice';
|
|
2234
|
+
conn.userModes.registered = true;
|
|
2235
|
+
const ctx = makeCtx(conn, services);
|
|
2236
|
+
|
|
2237
|
+
const out = chanservReducer(conn, privmsg('KICK #home alice'), ctx);
|
|
2238
|
+
|
|
2239
|
+
expect(firstBroadcast(out.effects)).toBeDefined();
|
|
2240
|
+
expect(firstApplyChannelDelta(out.effects)).toBeDefined();
|
|
2241
|
+
expect(sentTexts(out.effects)).toContain(
|
|
2242
|
+
':ChanServ!ChanServ@services NOTICE alice :Kicked alice from #home.',
|
|
2243
|
+
);
|
|
2244
|
+
});
|
|
2079
2245
|
});
|
|
2080
2246
|
|
|
2081
2247
|
// ============================================================================
|
|
@@ -1013,6 +1013,146 @@ describe('chathistoryReducer — TAGMSG replay', () => {
|
|
|
1013
1013
|
});
|
|
1014
1014
|
});
|
|
1015
1015
|
|
|
1016
|
+
// ============================================================================
|
|
1017
|
+
// Limit ceiling (L9)
|
|
1018
|
+
//
|
|
1019
|
+
// The client-supplied limit is capped at a deployment-configurable ceiling
|
|
1020
|
+
// (ServerConfig.chathistory.maxLimit, default 100) so an oversized request
|
|
1021
|
+
// like `CHATHISTORY LATEST #chan 999999999` cannot ask a future higher-
|
|
1022
|
+
// capacity store to materialize a huge result set. The cap is silent: the
|
|
1023
|
+
// client simply receives at most the ceiling worth of replay lines.
|
|
1024
|
+
// ============================================================================
|
|
1025
|
+
|
|
1026
|
+
describe('chathistoryReducer — limit ceiling', () => {
|
|
1027
|
+
/** Number of replay lines inside the emitted BATCH frame (0 when elided). */
|
|
1028
|
+
function replayCount(effects: EffectType[]): number {
|
|
1029
|
+
if (effects.length === 0) return 0;
|
|
1030
|
+
const send = effects[0] as { lines: RawLine[] };
|
|
1031
|
+
return send.lines.length - 2; // minus BATCH start + BATCH end
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Ctx over a store that retains more than the default ceiling. */
|
|
1035
|
+
function makeCappedCtx(conn: ConnectionState, store: MessageStore, config: ServerConfig): Ctx {
|
|
1036
|
+
return buildCtx({
|
|
1037
|
+
serverConfig: config,
|
|
1038
|
+
clock: new FakeClock(10_000),
|
|
1039
|
+
ids: new SequentialIdFactory(),
|
|
1040
|
+
motd: EmptyMotdProvider,
|
|
1041
|
+
messages: store,
|
|
1042
|
+
connection: conn,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
it('caps an oversized limit at the default ceiling of 100', () => {
|
|
1047
|
+
const store = new InMemoryMessageStore(1000);
|
|
1048
|
+
seed(store, '#foo', 150);
|
|
1049
|
+
const conn = makeConn();
|
|
1050
|
+
const chan = makeChan();
|
|
1051
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
1052
|
+
const ctx = makeCappedCtx(conn, store, serverConfig);
|
|
1053
|
+
|
|
1054
|
+
const out = chathistoryReducer(
|
|
1055
|
+
chan,
|
|
1056
|
+
{ command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '999999999'], tags: {} },
|
|
1057
|
+
ctx,
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
expect(replayCount(out.effects)).toBe(100);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('honors a limit at exactly the ceiling (100 passes through uncapped)', () => {
|
|
1064
|
+
const store = new InMemoryMessageStore(1000);
|
|
1065
|
+
seed(store, '#foo', 150);
|
|
1066
|
+
const conn = makeConn();
|
|
1067
|
+
const chan = makeChan();
|
|
1068
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
1069
|
+
const ctx = makeCappedCtx(conn, store, serverConfig);
|
|
1070
|
+
|
|
1071
|
+
const out = chathistoryReducer(
|
|
1072
|
+
chan,
|
|
1073
|
+
{ command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '100'], tags: {} },
|
|
1074
|
+
ctx,
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
expect(replayCount(out.effects)).toBe(100);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it('passes a below-ceiling limit through unchanged', () => {
|
|
1081
|
+
const store = new InMemoryMessageStore(1000);
|
|
1082
|
+
seed(store, '#foo', 150);
|
|
1083
|
+
const conn = makeConn();
|
|
1084
|
+
const chan = makeChan();
|
|
1085
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
1086
|
+
const ctx = makeCappedCtx(conn, store, serverConfig);
|
|
1087
|
+
|
|
1088
|
+
const out = chathistoryReducer(
|
|
1089
|
+
chan,
|
|
1090
|
+
{ command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },
|
|
1091
|
+
ctx,
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
expect(replayCount(out.effects)).toBe(5);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it('caps at a configured lower ceiling via serverConfig.chathistory.maxLimit', () => {
|
|
1098
|
+
const store = new InMemoryMessageStore(1000);
|
|
1099
|
+
seed(store, '#foo', 150);
|
|
1100
|
+
const conn = makeConn();
|
|
1101
|
+
const chan = makeChan();
|
|
1102
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
1103
|
+
const ctx = makeCappedCtx(conn, store, {
|
|
1104
|
+
...serverConfig,
|
|
1105
|
+
chathistory: { maxLimit: 3 },
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
const out = chathistoryReducer(
|
|
1109
|
+
chan,
|
|
1110
|
+
{ command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '999999999'], tags: {} },
|
|
1111
|
+
ctx,
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
expect(replayCount(out.effects)).toBe(3);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it('caps the default (omitted) limit at a configured lower ceiling', () => {
|
|
1118
|
+
const store = new InMemoryMessageStore(1000);
|
|
1119
|
+
seed(store, '#foo', 150);
|
|
1120
|
+
const conn = makeConn();
|
|
1121
|
+
const chan = makeChan();
|
|
1122
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
1123
|
+
const ctx = makeCappedCtx(conn, store, {
|
|
1124
|
+
...serverConfig,
|
|
1125
|
+
chathistory: { maxLimit: 3 },
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
const out = chathistoryReducer(
|
|
1129
|
+
chan,
|
|
1130
|
+
// No limit param — the default 50 must still respect the ceiling of 3.
|
|
1131
|
+
{ command: 'CHATHISTORY', params: ['LATEST', '#foo', '*'], tags: {} },
|
|
1132
|
+
ctx,
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
expect(replayCount(out.effects)).toBe(3);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it('returns 461 for a negative limit', () => {
|
|
1139
|
+
const store = new InMemoryMessageStore();
|
|
1140
|
+
seed(store, '#foo', 3);
|
|
1141
|
+
const conn = makeConn();
|
|
1142
|
+
const chan = makeChan();
|
|
1143
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
1144
|
+
const ctx = makeCtx(conn, store);
|
|
1145
|
+
|
|
1146
|
+
const out = chathistoryReducer(
|
|
1147
|
+
chan,
|
|
1148
|
+
{ command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '-5'], tags: {} },
|
|
1149
|
+
ctx,
|
|
1150
|
+
);
|
|
1151
|
+
|
|
1152
|
+
expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1016
1156
|
// ============================================================================
|
|
1017
1157
|
// No MessageStore bound (deployment disabled chathistory persistence)
|
|
1018
1158
|
// ============================================================================
|
|
@@ -37,6 +37,7 @@ const queueServerConfig: ServerConfig = {
|
|
|
37
37
|
const autoApproveServerConfig: ServerConfig = {
|
|
38
38
|
...serverConfig,
|
|
39
39
|
hostservAutoApproveVhosts: true,
|
|
40
|
+
hostservAutoApproveAllowlist: ['cool.example.net', 'one.example.net', 'two.example.net'],
|
|
40
41
|
};
|
|
41
42
|
|
|
42
43
|
function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
@@ -548,6 +549,321 @@ describe('hostservReducer — REQUEST (auto-approve knob explicitly on)', () =>
|
|
|
548
549
|
});
|
|
549
550
|
});
|
|
550
551
|
|
|
552
|
+
// ============================================================================
|
|
553
|
+
// REQUEST — auto-approve hardening (denylist + allowlist)
|
|
554
|
+
// ============================================================================
|
|
555
|
+
|
|
556
|
+
describe('hostservReducer — REQUEST (auto-approve denylist)', () => {
|
|
557
|
+
/**
|
|
558
|
+
* Auto-approve config whose allowlist deliberately contains every probed
|
|
559
|
+
* vhost: each test proves the built-in denylist is enforced regardless
|
|
560
|
+
* of (and ahead of) the allowlist.
|
|
561
|
+
*/
|
|
562
|
+
const denyConfig: ServerConfig = {
|
|
563
|
+
...serverConfig,
|
|
564
|
+
hostservAutoApproveVhosts: true,
|
|
565
|
+
hostservAutoApproveAllowlist: [
|
|
566
|
+
'admin.example.net',
|
|
567
|
+
'oper.example.net',
|
|
568
|
+
'staff.example.net',
|
|
569
|
+
'irc.example.net',
|
|
570
|
+
'www.example.net',
|
|
571
|
+
'root.example.net',
|
|
572
|
+
'security.example.net',
|
|
573
|
+
'user.admin.example.net',
|
|
574
|
+
'example.net.staff',
|
|
575
|
+
'ADMIN.example.net',
|
|
576
|
+
'hub.example.net',
|
|
577
|
+
'user.hub.example.net',
|
|
578
|
+
'clean.example.net',
|
|
579
|
+
],
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/** Same hardening config on a serverName free of reserved labels, so the
|
|
583
|
+
* own-hostname suffix check is probed in isolation. */
|
|
584
|
+
const suffixConfig: ServerConfig = { ...denyConfig, serverName: 'hub.example.net' };
|
|
585
|
+
|
|
586
|
+
it.each(['admin', 'oper', 'staff', 'irc', 'www', 'root', 'security'])(
|
|
587
|
+
'denies a vhost whose first label is the reserved label %s (even when allowlisted)',
|
|
588
|
+
(label) => {
|
|
589
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
590
|
+
const conn = identifiedConn('alice');
|
|
591
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
|
|
592
|
+
|
|
593
|
+
const out = hostservReducer(conn, privmsg(`REQUEST ${label}.example.net`), ctx);
|
|
594
|
+
|
|
595
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
596
|
+
expect(sentTexts(out.effects)).toContain(
|
|
597
|
+
`:HostServ!HostServ@services NOTICE alice :Vhost ${label}.example.net is not permitted (reserved label or this server's hostname).`,
|
|
598
|
+
);
|
|
599
|
+
},
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
it('denies a vhost with a reserved label in a middle position', () => {
|
|
603
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
604
|
+
const conn = identifiedConn('alice');
|
|
605
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
|
|
606
|
+
|
|
607
|
+
const out = hostservReducer(conn, privmsg('REQUEST user.admin.example.net'), ctx);
|
|
608
|
+
|
|
609
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
610
|
+
expect(sentTexts(out.effects)).toContain(
|
|
611
|
+
":HostServ!HostServ@services NOTICE alice :Vhost user.admin.example.net is not permitted (reserved label or this server's hostname).",
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('denies a vhost with a reserved label in the last position', () => {
|
|
616
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
617
|
+
const conn = identifiedConn('alice');
|
|
618
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
|
|
619
|
+
|
|
620
|
+
const out = hostservReducer(conn, privmsg('REQUEST example.net.staff'), ctx);
|
|
621
|
+
|
|
622
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
623
|
+
expect(sentTexts(out.effects)).toContain(
|
|
624
|
+
":HostServ!HostServ@services NOTICE alice :Vhost example.net.staff is not permitted (reserved label or this server's hostname).",
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('matches reserved labels case-insensitively', () => {
|
|
629
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
630
|
+
const conn = identifiedConn('alice');
|
|
631
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
|
|
632
|
+
|
|
633
|
+
const out = hostservReducer(conn, privmsg('REQUEST ADMIN.example.net'), ctx);
|
|
634
|
+
|
|
635
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
636
|
+
expect(sentTexts(out.effects)).toContain(
|
|
637
|
+
":HostServ!HostServ@services NOTICE alice :Vhost ADMIN.example.net is not permitted (reserved label or this server's hostname).",
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("denies the server's own hostname exactly (even when allowlisted)", () => {
|
|
642
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
643
|
+
const conn = identifiedConn('alice');
|
|
644
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), suffixConfig);
|
|
645
|
+
|
|
646
|
+
const out = hostservReducer(conn, privmsg('REQUEST hub.example.net'), ctx);
|
|
647
|
+
|
|
648
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
649
|
+
expect(sentTexts(out.effects)).toContain(
|
|
650
|
+
":HostServ!HostServ@services NOTICE alice :Vhost hub.example.net is not permitted (reserved label or this server's hostname).",
|
|
651
|
+
);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("denies a vhost ending with the server's own hostname", () => {
|
|
655
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
656
|
+
const conn = identifiedConn('alice');
|
|
657
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), suffixConfig);
|
|
658
|
+
|
|
659
|
+
const out = hostservReducer(conn, privmsg('REQUEST user.hub.example.net'), ctx);
|
|
660
|
+
|
|
661
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
662
|
+
expect(sentTexts(out.effects)).toContain(
|
|
663
|
+
":HostServ!HostServ@services NOTICE alice :Vhost user.hub.example.net is not permitted (reserved label or this server's hostname).",
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("denies the server's own hostname case-insensitively", () => {
|
|
668
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
669
|
+
const conn = identifiedConn('alice');
|
|
670
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), {
|
|
671
|
+
...suffixConfig,
|
|
672
|
+
serverName: 'HUB.example.net',
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
hostservReducer(conn, privmsg('REQUEST hub.example.net'), ctx);
|
|
676
|
+
|
|
677
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
describe('hostservReducer — REQUEST (auto-approve allowlist)', () => {
|
|
682
|
+
const allowConfig: ServerConfig = {
|
|
683
|
+
...serverConfig,
|
|
684
|
+
hostservAutoApproveVhosts: true,
|
|
685
|
+
hostservAutoApproveAllowlist: ['users.example.net', '*.vhosts.example.net'],
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
it('approves a vhost that exactly matches an allowlist entry', () => {
|
|
689
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
690
|
+
const conn = identifiedConn('alice');
|
|
691
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
692
|
+
|
|
693
|
+
const out = hostservReducer(conn, privmsg('REQUEST users.example.net'), ctx);
|
|
694
|
+
|
|
695
|
+
expect(services.getVhost('alice')).toBe('users.example.net');
|
|
696
|
+
expect(sentTexts(out.effects)).toContain(
|
|
697
|
+
':HostServ!HostServ@services NOTICE alice :Vhost users.example.net has been requested and approved.',
|
|
698
|
+
);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('matches an exact allowlist entry case-insensitively', () => {
|
|
702
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
703
|
+
const conn = identifiedConn('alice');
|
|
704
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
705
|
+
|
|
706
|
+
hostservReducer(conn, privmsg('REQUEST USERS.Example.NET'), ctx);
|
|
707
|
+
|
|
708
|
+
expect(services.getVhost('alice')).toBe('USERS.Example.NET');
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('denies a vhost with no allowlist entry (nothing recorded)', () => {
|
|
712
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
713
|
+
const conn = identifiedConn('alice');
|
|
714
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
715
|
+
|
|
716
|
+
const out = hostservReducer(conn, privmsg('REQUEST other.example.net'), ctx);
|
|
717
|
+
|
|
718
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
719
|
+
expect(services.getPendingVhost('alice')).toBeUndefined();
|
|
720
|
+
expect(sentTexts(out.effects)).toContain(
|
|
721
|
+
':HostServ!HostServ@services NOTICE alice :Vhost other.example.net is not in the auto-approve allowlist.',
|
|
722
|
+
);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('approves a vhost covered by a wildcard entry (*.suffix)', () => {
|
|
726
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
727
|
+
const conn = identifiedConn('alice');
|
|
728
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
729
|
+
|
|
730
|
+
const out = hostservReducer(conn, privmsg('REQUEST a.vhosts.example.net'), ctx);
|
|
731
|
+
|
|
732
|
+
expect(services.getVhost('alice')).toBe('a.vhosts.example.net');
|
|
733
|
+
expect(sentTexts(out.effects)).toContain(
|
|
734
|
+
':HostServ!HostServ@services NOTICE alice :Vhost a.vhosts.example.net has been requested and approved.',
|
|
735
|
+
);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('approves a vhost at any depth beneath a wildcard entry', () => {
|
|
739
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
740
|
+
const conn = identifiedConn('alice');
|
|
741
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
742
|
+
|
|
743
|
+
hostservReducer(conn, privmsg('REQUEST x.y.vhosts.example.net'), ctx);
|
|
744
|
+
|
|
745
|
+
expect(services.getVhost('alice')).toBe('x.y.vhosts.example.net');
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('matches a wildcard entry case-insensitively', () => {
|
|
749
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
750
|
+
const conn = identifiedConn('alice');
|
|
751
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
752
|
+
|
|
753
|
+
hostservReducer(conn, privmsg('REQUEST A.VHOSTS.Example.NET'), ctx);
|
|
754
|
+
|
|
755
|
+
expect(services.getVhost('alice')).toBe('A.VHOSTS.Example.NET');
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('does NOT match the bare suffix of a wildcard entry', () => {
|
|
759
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
760
|
+
const conn = identifiedConn('alice');
|
|
761
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
|
|
762
|
+
|
|
763
|
+
const out = hostservReducer(conn, privmsg('REQUEST vhosts.example.net'), ctx);
|
|
764
|
+
|
|
765
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
766
|
+
expect(sentTexts(out.effects)).toContain(
|
|
767
|
+
':HostServ!HostServ@services NOTICE alice :Vhost vhosts.example.net is not in the auto-approve allowlist.',
|
|
768
|
+
);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('checks every allowlist entry until one matches', () => {
|
|
772
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
773
|
+
const conn = identifiedConn('alice');
|
|
774
|
+
const cfg: ServerConfig = {
|
|
775
|
+
...serverConfig,
|
|
776
|
+
hostservAutoApproveVhosts: true,
|
|
777
|
+
hostservAutoApproveAllowlist: ['first.example.net', 'users.example.net'],
|
|
778
|
+
};
|
|
779
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), cfg);
|
|
780
|
+
|
|
781
|
+
hostservReducer(conn, privmsg('REQUEST users.example.net'), ctx);
|
|
782
|
+
|
|
783
|
+
expect(services.getVhost('alice')).toBe('users.example.net');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('fails closed when the allowlist is missing entirely (inline literal)', () => {
|
|
787
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
788
|
+
const conn = identifiedConn('alice');
|
|
789
|
+
// A booted server can never reach this shape (config parsing refuses
|
|
790
|
+
// auto-approve without an allowlist); the reducer must still deny
|
|
791
|
+
// everything rather than approve everything.
|
|
792
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), {
|
|
793
|
+
...serverConfig,
|
|
794
|
+
hostservAutoApproveVhosts: true,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const out = hostservReducer(conn, privmsg('REQUEST clean.example.net'), ctx);
|
|
798
|
+
|
|
799
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
800
|
+
expect(sentTexts(out.effects)).toContain(
|
|
801
|
+
':HostServ!HostServ@services NOTICE alice :Vhost clean.example.net is not in the auto-approve allowlist.',
|
|
802
|
+
);
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
describe('hostservReducer — hardening does not apply to oper review', () => {
|
|
807
|
+
it('queues a reserved-label vhost in queue mode (opers vet it)', () => {
|
|
808
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
809
|
+
const conn = identifiedConn('alice');
|
|
810
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), queueServerConfig);
|
|
811
|
+
|
|
812
|
+
const out = hostservReducer(conn, privmsg('REQUEST admin.example.net'), ctx);
|
|
813
|
+
|
|
814
|
+
expect(services.getPendingVhost('alice')).toBe('admin.example.net');
|
|
815
|
+
expect(services.getVhost('alice')).toBeUndefined();
|
|
816
|
+
expect(sentTexts(out.effects)).toContain(
|
|
817
|
+
':HostServ!HostServ@services NOTICE alice :Vhost admin.example.net is pending oper approval.',
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('queues a vhost outside the allowlist in queue mode', () => {
|
|
822
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
823
|
+
const conn = identifiedConn('alice');
|
|
824
|
+
const cfg: ServerConfig = {
|
|
825
|
+
...queueServerConfig,
|
|
826
|
+
hostservAutoApproveAllowlist: ['users.example.net'],
|
|
827
|
+
};
|
|
828
|
+
const ctx = makeCtx(conn, services, new FakeClock(NOW), cfg);
|
|
829
|
+
|
|
830
|
+
const out = hostservReducer(conn, privmsg('REQUEST other.example.net'), ctx);
|
|
831
|
+
|
|
832
|
+
expect(services.getPendingVhost('alice')).toBe('other.example.net');
|
|
833
|
+
expect(sentTexts(out.effects)).toContain(
|
|
834
|
+
':HostServ!HostServ@services NOTICE alice :Vhost other.example.net is pending oper approval.',
|
|
835
|
+
);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('lets an oper APPROVE a reserved-label vhost from the queue', () => {
|
|
839
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
840
|
+
services.requestVhost('alice', 'admin.example.net');
|
|
841
|
+
const conn = operConn('admin');
|
|
842
|
+
const ctx = makeCtx(conn, services);
|
|
843
|
+
|
|
844
|
+
const out = hostservReducer(conn, privmsg('APPROVE alice'), ctx);
|
|
845
|
+
|
|
846
|
+
expect(services.getVhost('alice')).toBe('admin.example.net');
|
|
847
|
+
expect(sentTexts(out.effects)).toContain(
|
|
848
|
+
':HostServ!HostServ@services NOTICE admin :Vhost admin.example.net for alice has been approved and activated.',
|
|
849
|
+
);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
it('lets an oper SET a reserved-label vhost directly', () => {
|
|
853
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|
|
854
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
855
|
+
const conn = operConn('admin');
|
|
856
|
+
const ctx = makeCtx(conn, services);
|
|
857
|
+
|
|
858
|
+
const out = hostservReducer(conn, privmsg('SET alice staff.example.net'), ctx);
|
|
859
|
+
|
|
860
|
+
expect(services.getVhost('alice')).toBe('staff.example.net');
|
|
861
|
+
expect(sentTexts(out.effects)).toContain(
|
|
862
|
+
':HostServ!HostServ@services NOTICE admin :Vhost for alice is now set to staff.example.net.',
|
|
863
|
+
);
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
551
867
|
// ============================================================================
|
|
552
868
|
// APPROVE / ACTIVATE (oper-only)
|
|
553
869
|
// ============================================================================
|