Cloudflare injects its JS-detection bootstrap inline into origin HTML. `script-src 'self'` stops it executing, so the challenge never records that it was solved and Cloudflare serves another one on the next request -- a loop no visitor can escape, which reads as a Cloudflare bug rather than a header of ours. Allowing it needs `'unsafe-inline'`, since an inline script cannot be allowlisted by URL and Cloudflare cannot know a nonce we generate. That is a real weakening, so it is opt-in: the default policy is unchanged byte for byte, and only an instance Cloudflare actually fronts turns it on. Nothing else in the policy relaxes. Co-Authored-By: Claude Opus 5 <[email protected]>
447 lines
16 KiB
Nix
447 lines
16 KiB
Nix
{
|
|
description = "A privacy-focused Twitter/X frontend";
|
|
|
|
outputs =
|
|
args:
|
|
let
|
|
inputs = (import ./.tack) { overrides = args.tackOverrides or { }; };
|
|
inherit (inputs) nixpkgs fenix;
|
|
inherit (nixpkgs) lib;
|
|
forAllSystems = lib.genAttrs (lib.systems.doubles.linux ++ lib.systems.doubles.darwin);
|
|
pkgsFor = system: nixpkgs.legacyPackages.${system} or (import nixpkgs { inherit system; });
|
|
|
|
hasFenix = system: fenix.packages ? ${system};
|
|
hasWild = platform: platform.isLinux && (platform.isx86_64 || platform.isAarch64);
|
|
nativeDeps =
|
|
pkgs:
|
|
lib.optionals (hasWild pkgs.stdenv.hostPlatform) [
|
|
pkgs.wild
|
|
pkgs.clang
|
|
];
|
|
rustPlatformFor =
|
|
system:
|
|
let
|
|
pkgs = pkgsFor system;
|
|
fenixPkgs = fenix.packages.${system}.latest;
|
|
in
|
|
if hasFenix system then
|
|
pkgs.makeRustPlatform { inherit (fenixPkgs) cargo rustc; }
|
|
else
|
|
pkgs.rustPlatform;
|
|
in
|
|
{
|
|
packages = forAllSystems (
|
|
system:
|
|
let
|
|
pkgs = pkgsFor system;
|
|
in
|
|
{
|
|
default = pkgs.callPackage ./nix/package.nix {
|
|
rustPlatform = rustPlatformFor system;
|
|
};
|
|
}
|
|
);
|
|
|
|
devShells = forAllSystems (
|
|
system:
|
|
let
|
|
pkgs = pkgsFor system;
|
|
toolchain =
|
|
if hasFenix system then
|
|
[ fenix.packages.${system}.latest.toolchain ]
|
|
else
|
|
(with pkgs; [
|
|
cargo
|
|
clippy
|
|
rustc
|
|
rustfmt
|
|
]);
|
|
in
|
|
{
|
|
default = pkgs.mkShell {
|
|
packages =
|
|
toolchain
|
|
++ [
|
|
pkgs.cargo-deny
|
|
pkgs.cargo-nextest
|
|
pkgs.rust-analyzer
|
|
pkgs.pkg-config
|
|
pkgs.ffmpeg-headless
|
|
]
|
|
++ nativeDeps pkgs;
|
|
};
|
|
}
|
|
);
|
|
|
|
nixosModules.default =
|
|
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.teapot;
|
|
messageType = lib.types.listOf lib.types.str;
|
|
configFile = (pkgs.formats.toml { }).generate "teapot.toml" (
|
|
lib.recursiveUpdate {
|
|
inherit (cfg) cache preferences gifTranscoding;
|
|
config = lib.filterAttrs (_: v: v != null) cfg.config // {
|
|
hmacKey = "@hmac@";
|
|
kagiToken = "@kagi@";
|
|
};
|
|
server = lib.filterAttrs (_: v: v != null) cfg.server // {
|
|
staticDir = "${cfg.package}/share/teapot/public";
|
|
};
|
|
} cfg.settings
|
|
);
|
|
preStart = pkgs.writers.writePython3 "teapot-prestart" { } /* py */ ''
|
|
import os
|
|
import secrets
|
|
|
|
state_dir = os.environ.get("STATE_DIRECTORY")
|
|
creds_dir = os.environ.get("CREDENTIALS_DIRECTORY", "")
|
|
if not os.path.isfile(f"{state_dir}/hmac"):
|
|
hmac = secrets.token_hex(32)
|
|
with open(f"{state_dir}/hmac", "w") as f:
|
|
f.write(hmac)
|
|
else:
|
|
with open(f"{state_dir}/hmac", "r") as f:
|
|
hmac = f.read()
|
|
|
|
kagi = ""
|
|
kagi_cred = os.path.join(creds_dir, "kagiToken") if creds_dir else ""
|
|
if kagi_cred and os.path.isfile(kagi_cred):
|
|
with open(kagi_cred, "r") as f:
|
|
kagi = f.read().strip()
|
|
|
|
configFile = "${configFile}"
|
|
with open(configFile, "r") as f_in:
|
|
content = f_in.read()
|
|
content = content.replace("@hmac@", hmac)
|
|
content = content.replace("@kagi@", kagi)
|
|
with open(f"{state_dir}/teapot.toml", "w") as f_out:
|
|
f_out.write(content)
|
|
'';
|
|
in
|
|
{
|
|
options.services.teapot = {
|
|
enable = lib.mkEnableOption "teapot, a privacy-focused Twitter/X frontend";
|
|
|
|
package = lib.mkOption {
|
|
type = lib.types.package;
|
|
default = pkgs.callPackage ./nix/package.nix { };
|
|
defaultText = lib.literalExpression "pkgs.callPackage ./nix/package.nix { }";
|
|
description = "The teapot package to use.";
|
|
};
|
|
|
|
server = {
|
|
hostname = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "localhost";
|
|
example = "teapot.example.com";
|
|
description = "Hostname of the instance.";
|
|
};
|
|
title = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "teapot";
|
|
description = "Title of the instance.";
|
|
};
|
|
address = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "0.0.0.0";
|
|
example = "127.0.0.1";
|
|
description = "The address to listen on.";
|
|
};
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 8080;
|
|
description = "The port to listen on.";
|
|
};
|
|
publicPort = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.port;
|
|
default = null;
|
|
description = "Port used for generating public URLs. Defaults to the listen port.";
|
|
};
|
|
https = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = "Set secure attribute on cookies. Enable when using HTTPS.";
|
|
};
|
|
httpMaxConnections = lib.mkOption {
|
|
type = lib.types.int;
|
|
default = 100;
|
|
description = "Maximum number of HTTP connections.";
|
|
};
|
|
};
|
|
|
|
cache = {
|
|
listMinutes = lib.mkOption {
|
|
type = lib.types.int;
|
|
default = 240;
|
|
description = "How long to cache list info (minutes).";
|
|
};
|
|
rssMinutes = lib.mkOption {
|
|
type = lib.types.int;
|
|
default = 10;
|
|
description = "How long to cache RSS queries (minutes).";
|
|
};
|
|
maxEntries = lib.mkOption {
|
|
type = lib.types.ints.positive;
|
|
default = 50000;
|
|
description = "Maximum number of entries in the in-process cache.";
|
|
};
|
|
};
|
|
|
|
config = {
|
|
base64Media = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = "Use base64 encoding for proxied media URLs.";
|
|
};
|
|
enableRSS = lib.mkEnableOption "RSS feeds" // {
|
|
default = true;
|
|
};
|
|
enableDebug = lib.mkEnableOption "request logs and debug endpoints";
|
|
clientBudget = lib.mkEnableOption "per-client upstream call budget" // {
|
|
default = true;
|
|
};
|
|
trustedProxies = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [ ];
|
|
example = [ "::1" ];
|
|
description = ''
|
|
Peers whose X-Forwarded-For and X-Real-IP headers name the real
|
|
client. Any other peer is billed by its socket address, since
|
|
those headers are otherwise caller-controlled.
|
|
'';
|
|
};
|
|
behindCloudflare = lib.mkEnableOption (
|
|
"a Content-Security-Policy that permits Cloudflare's challenge "
|
|
+ "bootstrap. Cloudflare injects its JS detection inline, which the "
|
|
+ "default script-src blocks, leaving challenges unsolvable in a "
|
|
+ "loop. Costs an 'unsafe-inline' for scripts, so enable it only "
|
|
+ "when Cloudflare fronts the instance"
|
|
);
|
|
clientBudgetMessage = lib.mkOption {
|
|
type = messageType;
|
|
default = [ ];
|
|
example = [
|
|
"Slow down."
|
|
"Still too fast."
|
|
];
|
|
description = "Shown when a caller outruns its own budget. A list is rotated through.";
|
|
};
|
|
rateLimitedMessage = lib.mkOption {
|
|
type = messageType;
|
|
default = [ ];
|
|
description = "Shown when the instance is out of upstream quota. A list is rotated through.";
|
|
};
|
|
internalErrorMessage = lib.mkOption {
|
|
type = messageType;
|
|
default = [ ];
|
|
description = "Shown when a request fails for any other reason. A list is rotated through.";
|
|
};
|
|
proxy = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
description = "URL to a HTTP/HTTPS proxy.";
|
|
};
|
|
proxyAuth = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
description = "Credentials for proxy.";
|
|
};
|
|
apiProxy = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
description = "HTTP/HTTPS proxy URL used only for X API requests.";
|
|
};
|
|
disableTid = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = "Disable TID for cookie-based auth.";
|
|
};
|
|
xPosedCommunityCache = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = ''
|
|
Read about-account data from X-Posed's public cache before
|
|
asking X, saving a call on the scarcest rate-limit tier.
|
|
Off by default because it discloses the usernames being
|
|
viewed to a third party. teapot never contributes to it.
|
|
'';
|
|
};
|
|
maxConcurrentReqs = lib.mkOption {
|
|
type = lib.types.int;
|
|
default = 2;
|
|
description = "Max concurrent requests per session.";
|
|
};
|
|
paidEmoji = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "🤝";
|
|
description = "Emoji for paid promotion disclosure labels.";
|
|
};
|
|
aiEmoji = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "🤖";
|
|
description = "Emoji for AI-generated content disclosure labels.";
|
|
};
|
|
};
|
|
|
|
gifTranscoding = {
|
|
mode = lib.mkOption {
|
|
type = lib.types.enum [
|
|
"off"
|
|
"local"
|
|
"external"
|
|
];
|
|
default = "local";
|
|
description = "GIF transcoding mode. 'local' requires ffmpeg, 'external' rewrites URLs to an external domain.";
|
|
};
|
|
cacheDir = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "/var/lib/teapot/cache/gif";
|
|
description = "Cache directory for transcoded GIFs (local mode).";
|
|
};
|
|
cacheMaxMb = lib.mkOption {
|
|
type = lib.types.int;
|
|
default = 512;
|
|
description = "Maximum cache size in MB (local mode).";
|
|
};
|
|
externalDomain = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
description = "External transcoding domain (external mode).";
|
|
};
|
|
};
|
|
|
|
preferences = {
|
|
theme = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "teapot";
|
|
description = "Instance theme.";
|
|
};
|
|
replaceTwitter = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
description = "Replace Twitter links with links to this instance.";
|
|
};
|
|
replaceYouTube = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
description = "Replace YouTube links with this instance.";
|
|
};
|
|
replaceReddit = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
description = "Replace Reddit links with this instance.";
|
|
};
|
|
infiniteScroll = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = "Infinite scrolling (requires JavaScript).";
|
|
};
|
|
};
|
|
|
|
settings = lib.mkOption {
|
|
type = lib.types.attrs;
|
|
default = { };
|
|
description = "Additional settings to override module-generated config.";
|
|
};
|
|
|
|
sessionsFile = lib.mkOption {
|
|
type = lib.types.path;
|
|
default = "/var/lib/teapot/sessions.jsonl";
|
|
description = ''
|
|
Path to the session tokens file (JSONL format).
|
|
|
|
Each line: {"kind":"oauth","oauth_token":"...","oauth_secret":"..."}
|
|
'';
|
|
};
|
|
|
|
kagiTokenFile = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.path;
|
|
default = null;
|
|
description = ''
|
|
Path to a file containing a Kagi session token for server-side
|
|
translation. When set, non-English tweets are translated via
|
|
Kagi Translate instead of Twitter's Strato API.
|
|
|
|
Get your token from kagi.com/settings → Session Link.
|
|
'';
|
|
};
|
|
|
|
openFirewall = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = "Open ports in the firewall for the web interface.";
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
systemd.services.teapot = {
|
|
description = "teapot (privacy-focused Twitter/X frontend)";
|
|
wantedBy = [ "multi-user.target" ];
|
|
wants = [ "network-online.target" ];
|
|
after = [ "network-online.target" ];
|
|
path = lib.mkIf (cfg.gifTranscoding.mode == "local") [ pkgs.ffmpeg-headless ];
|
|
serviceConfig = {
|
|
DynamicUser = true;
|
|
LoadCredential = [
|
|
"sessionsFile:${cfg.sessionsFile}"
|
|
]
|
|
++ lib.optional (cfg.kagiTokenFile != null) "kagiToken:${cfg.kagiTokenFile}";
|
|
StateDirectory = "teapot";
|
|
Environment = [
|
|
"TEAPOT_CONF_FILE=/var/lib/teapot/teapot.toml"
|
|
"TEAPOT_SESSIONS_FILE=%d/sessionsFile"
|
|
"TEAPOT_SESSION_STATE_FILE=/var/lib/teapot/session-limits.json"
|
|
];
|
|
WorkingDirectory = "${cfg.package}/share/teapot";
|
|
ExecStart = "${cfg.package}/bin/teapot";
|
|
ExecStartPre = "${preStart}";
|
|
AmbientCapabilities = lib.mkIf (cfg.server.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
|
|
Restart = "on-failure";
|
|
RestartSec = "5s";
|
|
# Hardening
|
|
CapabilityBoundingSet = if (cfg.server.port < 1024) then [ "CAP_NET_BIND_SERVICE" ] else [ "" ];
|
|
DeviceAllow = [ "" ];
|
|
LockPersonality = true;
|
|
MemoryDenyWriteExecute = true;
|
|
PrivateDevices = true;
|
|
PrivateUsers = cfg.server.port >= 1024;
|
|
ProcSubset = "pid";
|
|
ProtectClock = true;
|
|
ProtectControlGroups = true;
|
|
ProtectHome = true;
|
|
ProtectHostname = true;
|
|
ProtectKernelLogs = true;
|
|
ProtectKernelModules = true;
|
|
ProtectKernelTunables = true;
|
|
ProtectProc = "invisible";
|
|
RestrictAddressFamilies = [
|
|
"AF_INET"
|
|
"AF_INET6"
|
|
];
|
|
RestrictNamespaces = true;
|
|
RestrictRealtime = true;
|
|
RestrictSUIDSGID = true;
|
|
SystemCallArchitectures = "native";
|
|
SystemCallFilter = [
|
|
"@system-service"
|
|
"~@privileged"
|
|
"~@resources"
|
|
];
|
|
UMask = "0077";
|
|
};
|
|
};
|
|
|
|
networking.firewall = lib.mkIf cfg.openFirewall {
|
|
allowedTCPPorts = [ cfg.server.port ];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|