snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:05:30 +05:00
commit 64898b2c79
8999 changed files with 1757358 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
# This file is empty since we want nginx to serve ports 80 and 443 on
# this system.

View File

@@ -0,0 +1,50 @@
# Copyright © 2013 Zulip, Inc. All rights reserved.
# Copy, modify, and enable this site if you need to use an Apache httpd module
# for user authentication.
# Requests to /accounts/login/sso/ will be passed unchanged by the primary
# Zulip web server to Apache. Once a request is authenticated, the REMOTE_USER
# environment variable should be set by your authentication module.
# The config below will then invoke the Zulip web app under Apache, which will
# detect the presence of the variable, log the user in, and then redirect back
# to the app (served by the primary web server). You should configure your
# authentication module below.
NameVirtualHost 127.0.0.1:8888
Listen 127.0.0.1:8888
<VirtualHost 127.0.0.1:8888>
# As an example, we've configured this service to use HTTP basic auth.
# Insert the configuration for your SSO authentication module here:
<Location />
AuthType basic
AuthName "zulip.example.com"
AuthUserFile /home/zulip/zpasswd
Require valid-user
</Location>
ServerAdmin webmaster@localhost
ServerSignature Off
# You shouldn't need to edit anything below this line.
SSLEngine On
SSLCertificateFile /etc/ssl/certs/zulip.combined-chain.crt
SSLCertificateKeyFile /etc/ssl/private/zulip.key
WSGIScriptAlias / /home/zulip/deployments/current/zproject/wsgi.py
WSGIDaemonProcess zulip threads=5 user=zulip python-path=/home/zulip/deployments/current/
WSGIProcessGroup zulip
WSGIApplicationGroup %{GLOBAL}
ErrorLog ${APACHE_LOG_DIR}/zulip_auth_error.log
# Possible values include: debug, info, notice, warn, error, crit,
# alert, emerg.
LogLevel warn
CustomLog ${APACHE_LOG_DIR}/zulip_auth_access.log combined
</VirtualHost>

View File

@@ -0,0 +1,3 @@
MAILTO=root
* * * * * zulip cd /home/zulip/deployments/current && ./manage.py email_mirror

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -e
set -u
if ! grep -Eq 'SENTRY_DSN|SENTRY_FRONTEND_DSN' /etc/zulip/settings.py; then
echo "sentry: No DSN configured! Set SENTRY_DSN or SENTRY_FRONTEND_DSN in /etc/zulip/settings.py"
exit 0
fi
if ! SENTRY_AUTH_TOKEN=$(crudini --get /etc/zulip/zulip-secrets.conf secrets sentry_release_auth_token); then
echo "sentry: No release auth token set! Set sentry_release_auth_token in /etc/zulip/zulip-secrets.conf"
exit 0
fi
export SENTRY_AUTH_TOKEN
# shellcheck disable=SC2034
if ! sentry_org=$(crudini --get /etc/zulip/zulip.conf sentry organization); then
echo "sentry: No organization set! Set sentry.organization in /etc/zulip/zulip.conf"
exit 0
fi
sentry_project=$(crudini --get /etc/zulip/zulip.conf sentry project)
sentry_frontend_project=$(crudini --get /etc/zulip/zulip.conf sentry frontend_project)
if [ -z "$sentry_project" ] && [ -z "$sentry_frontend_project" ]; then
echo "sentry: No project set! Set sentry.project and/or sentry.frontend_project in /etc/zulip/zulip.conf"
exit 0
fi
if [ -n "$sentry_project" ] && ! grep -q 'SENTRY_DSN' /etc/zulip/settings.py; then
echo "sentry: sentry.project is set but SENTRY_DSN is not set in /etc/zulip/settings.py"
exit 0
fi
if [ -n "$sentry_frontend_project" ] && ! grep -q 'SENTRY_FRONTEND_DSN' /etc/zulip/settings.py; then
echo "sentry: sentry.frontend_project is set but SENTRY_FRONTEND_DSN is not set in /etc/zulip/settings.py"
exit 0
fi
if ! which sentry-cli >/dev/null; then
echo "sentry: No sentry-cli installed!"
exit 0
fi
# shellcheck disable=SC2034
sentry_release="zulip-server@$ZULIP_NEW_VERSION"

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -e
set -u
if ! zulip_api_key=$(crudini --get /etc/zulip/zulip-secrets.conf secrets zulip_release_api_key); then
echo "zulip_notify: No zulip_release_api_key set! Set zulip_release_api_key in /etc/zulip/zulip-secrets.conf"
exit 0
fi
if ! zulip_notify_bot_email=$(crudini --get /etc/zulip/zulip.conf zulip_notify bot_email); then
echo "zulip_notify: No zulip_notify.bot_email set in /etc/zulip/zulip.conf"
exit 0
fi
if ! zulip_notify_server=$(crudini --get /etc/zulip/zulip.conf zulip_notify server); then
echo "zulip_notify: No zulip_notify.server set in /etc/zulip/zulip.conf"
exit 0
fi
if ! zulip_notify_stream=$(crudini --get /etc/zulip/zulip.conf zulip_notify stream); then
echo "zulip_notify: No zulip_notify.stream set in /etc/zulip/zulip.conf"
exit 0
fi
from=${ZULIP_OLD_MERGE_BASE_COMMIT:-$ZULIP_OLD_VERSION}
to=${ZULIP_NEW_MERGE_BASE_COMMIT:-$ZULIP_NEW_VERSION}
deploy_environment=$(crudini --get /etc/zulip/zulip.conf machine deploy_type || echo "development")
# shellcheck disable=SC2034
commit_count=$(git rev-list "${from}..${to}" | wc -l)
# Sending the notification is best-effort: a failure to deliver it
# (e.g. the bot lacks posting permission, or the server is
# unreachable) must never abort a deploy, so we swallow the error
# after logging it.
zulip_send() {
if ! uv run --no-sync zulip-send \
--site "$zulip_notify_server" \
--user "$zulip_notify_bot_email" \
--api-key "$zulip_api_key" \
--stream "$zulip_notify_stream" \
--subject "$deploy_environment deploy" \
--message "$1"; then
echo "zulip_notify: Failed to send deploy notification; continuing anyway."
fi
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -e
set -u
if [ -z "${ZULIP_NEW_MERGE_BASE_COMMIT:-}" ]; then
echo "push_git_ref: Can't push because deploy is not from git!"
exit 0
fi
if ! repo=$(crudini --get /etc/zulip/zulip.conf push_git_ref repo); then
echo "push_git_ref: No repo set! Set push_git_ref.repo in /etc/zulip/zulip.conf"
exit 0
fi
if ! ref=$(crudini --get /etc/zulip/zulip.conf push_git_ref ref); then
echo "push_git_ref: No ref set! Set push_git_ref.ref in /etc/zulip/zulip.conf"
exit 0
fi
git push -f "$repo" "$ZULIP_NEW_MERGE_BASE_COMMIT:$ref"

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
/home/zulip/deployments/current/manage.py send_zulip_update_announcements --automated --progress

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -e
set -u
source "$(dirname "$0")/../common/sentry.sh"
deploy_environment=$(crudini --get /etc/zulip/zulip.conf machine deploy_type || echo "development")
echo "sentry: Adding deploy of '$deploy_environment' and finalizing release"
sentry-cli releases --org="$sentry_org" deploys "$sentry_release" new \
--env "$deploy_environment" \
--started "$(stat -c %Y ./sentry-release)" \
--finished "$(date +%s)" \
--name "$(hostname --fqdn)"
sentry-cli releases --org="$sentry_org" finalize "$sentry_release"

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -e
set -u
source "$(dirname "$0")/../common/zulip_notify.sh"
echo "zulip_notify: Sending notify of $from .. $to ($commit_count commits) for $deploy_environment to $zulip_notify_server"
zulip_send "Finished ${deploy_environment} deploy of [${commit_count} new commits](https://github.com/zulip/zulip/compare/${from}...${to}) on $(hostname)"

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -e
set -u
source "$(dirname "$0")/../common/sentry.sh"
echo "$sentry_release" >./sentry-release
echo "sentry: Creating release $sentry_release"
# sentry-cli only supports passing one project when making a new
# release, and we want to possibly create more than once at once. Use
# curl to make the API request.
json=$(jq -nc '{version: $ARGS.named.version,
projects: $ARGS.positional | map(select( . != ""))}' \
--arg version "$sentry_release" \
--args "$sentry_project" "$sentry_frontend_project")
curl "https://sentry.io/api/0/organizations/$sentry_org/releases/" \
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
-H 'Content-Type: application/json' \
-d "$json" \
--silent -o /dev/null
merge_base="${ZULIP_NEW_MERGE_BASE_COMMIT:-}"
if [ -n "$merge_base" ]; then
old_merge_base="$ZULIP_OLD_MERGE_BASE_COMMIT"
echo "sentry: Setting commit range based on merge-base to upstream of $merge_base"
sentry-cli releases --org="$sentry_org" set-commits "$sentry_release" --commit="zulip/zulip@$old_merge_base..$merge_base"
fi
if [ -n "$sentry_frontend_project" ]; then
echo "sentry: Uploading sourcemaps"
sentry-cli releases --org="$sentry_org" --project="$sentry_frontend_project" files "$sentry_release" upload-sourcemaps static/webpack-bundles/
fi

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -e
set -u
source "$(dirname "$0")/../common/zulip_notify.sh"
echo "zulip_notify: Sending notify of $from .. $to ($commit_count commits) for $deploy_environment to $zulip_notify_server"
zulip_send "Starting ${deploy_environment} deploy of [${commit_count} new commits](https://github.com/zulip/zulip/compare/${from}...${to}) on $(hostname)"

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
symlink_with_backup() {
if [ -e "$2" ]; then
# If the user is setting up our automatic certbot-management on a
# system that already has certs for Zulip, use some extra caution
# to keep the old certs available.
mv -f --backup=numbered "$2" "$2".setup-certbot || true
fi
ln -nsf "$1" "$2"
}
if [ -n "${ZULIP_DOMAIN:-}" ]; then
CERT_DIR="/etc/letsencrypt/live/$ZULIP_DOMAIN"
symlink_with_backup "$CERT_DIR/privkey.pem" /etc/ssl/private/zulip.key
symlink_with_backup "$CERT_DIR/fullchain.pem" /etc/ssl/certs/zulip.combined-chain.crt
fi

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
service nginx reload

View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
supervisorctl restart zulip-email-server

View File

@@ -0,0 +1,4 @@
zulip soft nofile 1000000
zulip hard nofile 1048576
root soft nofile 1000000
root hard nofile 1048576

View File

@@ -0,0 +1,10 @@
/var/log/zulip/smokescreen.log
{
missingok
rotate 14
daily
compress
delaycompress
notifempty
copytruncate
}

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""
Nagios plugin paired with a cron job. This just verifies that the
file output by the cron job is correct.
"""
import sys
import time
def nagios_from_file(results_file: str, max_time_diff: int = 60 * 2) -> tuple[int, str]:
"""Returns a nagios-appropriate string and return code obtained by
parsing the desired file on disk. The file on disk should be of format
%s|%s % (timestamp, nagios_string)
This file is created by various nagios checking cron jobs such as
check-rabbitmq-queues and check-rabbitmq-consumers"""
try:
with open(results_file) as f:
data = f.read().strip()
except FileNotFoundError:
state = "UNKNOWN"
ret = 3
data = "Results file is missing"
else:
pieces = data.split("|")
if len(pieces) != 4:
state = "UNKNOWN"
ret = 3
data = "Results file malformed"
else:
timestamp = int(pieces[0])
time_diff = int(time.time() - timestamp)
if time_diff > max_time_diff:
ret = 3
state = "UNKNOWN"
data = "Results file is stale"
else:
ret = int(pieces[1])
state = pieces[2]
data = pieces[3]
return (ret, f"{state}: {data}")
if __name__ == "__main__":
RESULTS_FILE = sys.argv[1]
extra_args = {}
if len(sys.argv) > 2:
extra_args["max_time_diff"] = int(sys.argv[2])
ret, result = nagios_from_file(RESULTS_FILE, **extra_args)
print(result)
sys.exit(ret)

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""
Nagios plugin to check that none of our queue workers have reported errors.
"""
import glob
import os
import sys
# settings.QUEUE_ERROR_DIR; not importing Django so that this can run
# as the nagios user.
wildcard = os.path.join("/var/log/zulip/queue_error", "*.errors")
clean = True
for fn in glob.glob(wildcard):
print(f"WARNING: Queue errors logged in {fn}")
clean = False
if not clean:
sys.exit(1)
sys.exit(0)

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
Script to provide information about send-receive times.
It must be run on a machine that is using the live database for the
Django ORM.
"""
import argparse
import os
import random
import sys
import time
import traceback
from typing import Any, Literal, NoReturn
sys.path.append(".")
sys.path.append("/home/zulip/deployments/current")
from scripts.lib.setup_path import setup_path
from scripts.lib.zulip_tools import atomic_nagios_write
setup_path()
import django
import zulip
sys.path.append("/home/zulip/deployments/current")
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
django.setup()
from django.conf import settings
from zerver.models.realms import get_realm
from zerver.models.users import get_system_bot
from zproject.config import get_config
usage = """Usage: send-receive.py [options] [config]
'config' is optional, if present will return config info.
Otherwise, returns the output data."""
parser = argparse.ArgumentParser(usage=usage)
parser.add_argument("--site", default=f"https://{settings.NAGIOS_BOT_HOST}")
parser.add_argument("--insecure", action="store_true")
options = parser.parse_args()
def report(
state: Literal["ok", "warning", "critical", "unknown"],
timestamp: float | None = None,
msg: str | None = None,
) -> NoReturn:
if msg is None:
msg = f"send time was {timestamp}"
sys.exit(atomic_nagios_write("check_send_receive_state", state, msg))
def send_zulip(sender: zulip.Client, message: dict[str, Any]) -> None:
result = sender.send_message(message)
if result["result"] != "success":
report("critical", msg=f"Error sending Zulip, args were: {message}, {result}")
def get_zulips() -> list[dict[str, Any]]:
global last_event_id
res = zulip_recipient.get_events(queue_id=queue_id, last_event_id=last_event_id)
if "error" in res.get("result", {}):
report("critical", msg="Error receiving Zulips, error was: {}".format(res["msg"]))
for event in res["events"]:
last_event_id = max(last_event_id, int(event["id"]))
# If we get a heartbeat event, that means we've been hanging for
# 40s, and we should bail.
if "heartbeat" in (event["type"] for event in res["events"]):
report("critical", msg="Got heartbeat waiting for Zulip, which means get_events is hanging")
return [event["message"] for event in res["events"]]
internal_realm_id = get_realm(settings.SYSTEM_BOT_REALM).id
if (
get_config("machine", "deploy_type") == "staging"
and settings.NAGIOS_STAGING_SEND_BOT is not None
and settings.NAGIOS_STAGING_RECEIVE_BOT is not None
):
sender = get_system_bot(settings.NAGIOS_STAGING_SEND_BOT, internal_realm_id)
recipient = get_system_bot(settings.NAGIOS_STAGING_RECEIVE_BOT, internal_realm_id)
else:
sender = get_system_bot(settings.NAGIOS_SEND_BOT, internal_realm_id)
recipient = get_system_bot(settings.NAGIOS_RECEIVE_BOT, internal_realm_id)
zulip_sender = zulip.Client(
email=sender.email,
api_key=sender.api_key,
verbose=True,
insecure=options.insecure,
client="ZulipMonitoring/0.1",
site=options.site,
)
zulip_recipient = zulip.Client(
email=recipient.email,
api_key=recipient.api_key,
verbose=True,
insecure=options.insecure,
client="ZulipMonitoring/0.1",
site=options.site,
)
try:
res = zulip_recipient.register(event_types=["message"])
if "error" in res.get("result", {}):
report("critical", msg="Error subscribing to Zulips: {}".format(res["msg"]))
queue_id, last_event_id = (res["queue_id"], res["last_event_id"])
except Exception:
report("critical", msg=f"Error subscribing to Zulips:\n{traceback.format_exc()}")
msg_to_send = str(random.getrandbits(64))
time_start = time.perf_counter()
send_zulip(
zulip_sender,
{
"type": "private",
"content": msg_to_send,
"subject": "time to send",
"to": recipient.email,
},
)
complete = False
while not complete:
messages = get_zulips()
seconds_diff = time.perf_counter() - time_start
for m in messages:
if msg_to_send == m["content"]:
zulip_sender.delete_message(m["id"])
complete = True
break
zulip_recipient.deregister(queue_id)
if seconds_diff > 12:
report("critical", timestamp=seconds_diff)
if seconds_diff > 3:
report("warning", timestamp=seconds_diff)
else:
report("ok", timestamp=seconds_diff)

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Checks for any Zulip queue workers that are leaking memory and thus have a high vsize
datafile=$(mktemp)
# We expect other Nagios checks to monitor for whether no queue
# workers are running, so we give that condition a pass
processes=$(pgrep -xf 'python.* /home/zulip/deployments/current/manage.py process_queue .*')
if [ -z "$processes" ]; then
echo "No workers running"
exit 0
fi
mapfile -t processes <<<"$processes"
ps -o vsize,size,pid,user,command --sort -vsize "${processes[@]}" >"$datafile"
cat "$datafile"
top_worker=$(head -n2 "$datafile" | tail -n1)
top_worker_memory_usage=$(echo "$top_worker" | cut -f1 -d" ")
rm -f "$datafile"
if [ "$top_worker_memory_usage" -gt 800000 ]; then
exit 2
elif [ "$top_worker_memory_usage" -gt 600000 ]; then
exit 1
else
exit 0
fi

View File

@@ -0,0 +1,146 @@
#!/usr/bin/perl -w
#
# check_debian_packages - nagios plugin
#
#
# Copyright (C) 2005 Francesc Guasch
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# Report bugs to: frankie@etsetb.upc.edu
#
use strict;
use lib '/usr/lib/nagios/plugins';
use utils qw(%ERRORS &print_revision &support &usage);
use Getopt::Long;
my $VERSION = '0.06';
my $RET = 'OK';
my $LOCK_FILE = "/var/lib/dpkg/lock";
my $CMD_APT = "/usr/bin/apt-get -s upgrade";
my $TIMEOUT = 60;
my $DEBUG = 0;
#####################################################################
#
# Command line arguments
#
sub print_usage ();
my ($help,$version);
GetOptions( help => \$help,
debug => \$DEBUG,
version => \$version,
'timeout=s' => \$TIMEOUT
);
my ($PROGNAME) = $0 =~ m#.*/(.*)#;
if ($help) {
print_revision($PROGNAME,"\$Revision: $VERSION \$");
print "Copyright (c) 2005 Francesc Guasch - Ortiz
Perl Check debian packages plugin for Nagios
";
print_usage();
exit($ERRORS{OK});
}
if ($version) {
print_revision($PROGNAME,"\$Revision: $VERSION \$");
exit($ERRORS{OK});
}
#
# unlikely but compliant
#
$SIG{'ALRM'} = sub {
print ("ERROR: Timeout\n");
exit $ERRORS{"UNKNOWN"};
};
alarm($TIMEOUT);
######################################################################
#
# subs
#
sub print_usage () {
print "Usage: $PROGNAME [--debug] [--version] [--help]"
." [--timeout=$TIMEOUT]\n";
}
sub add_info {
my ($info,$type,$pkg) = @_;
$$info .= scalar(keys %$pkg)." new pkgs in $type: ";
if (keys %$pkg< 5 ) {
$$info .= join " ",keys %$pkg;
} else {
my $alguns = join " ",keys %$pkg;
$alguns = substr($alguns,0,80);
$alguns .= "...";
$$info .= $alguns;
}
}
sub exit_unknown {
my ($info) = @_;
chomp $info;
$RET='UNKNOWN';
print "$RET: $info\n";
exit $ERRORS{$RET};
};
sub run_apt {
my ($pkg,$ver,$type,$release);
open APT,"$CMD_APT 2>&1|" or exit_unknown($!);
my (%stable,%security,%other);
while (<APT>) {
print "APT: $_" if $DEBUG;
exit_unknown($_) if /(Could not open lock file)|(Could not get lock)/;
next unless /^Inst/;
($pkg,$ver,$release) = /Inst (.*?) .*\((.*?) (.*?)\)/;
print "$_\npkg=$pkg ver=$ver release=$release\n" if $DEBUG;
die "$_\n" unless defined $release;
$release = 'stable'
if $release =~ /stable$/ && $release !~/security/i;
$release = 'security'
if $release =~ /security/i;
if ($release eq 'stable') {
$stable{$pkg} = $ver;
} elsif ($release eq 'security') {
$security{$pkg} = $ver;
} else {
$other{$pkg}=$ver;
}
}
close APT;
my $info = '';
if (keys (%security)) {
$RET = 'CRITICAL';
add_info(\$info,'security',\%security);
} elsif (keys (%other) or keys(%stable)) {
$RET = 'WARNING';
add_info(\$info,'stable',\%stable);
add_info(\$info,'other',\%other) if keys %other;
}
print "$RET: $info\n";
}
run_apt();
exit $ERRORS{$RET};

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Nagios plugin to check the difference between the primary and
replica PostgreSQL servers' xlog location. Requires that the user this
connects to PostgreSQL as has been granted the `pg_monitor` role.
This can only use stdlib modules from python.
"""
import configparser
import re
import subprocess
import sys
def get_config(
config_file: configparser.RawConfigParser,
section: str,
key: str,
default_value: str = "",
) -> str:
if config_file.has_option(section, key):
return config_file.get(section, key)
return default_value
config_file = configparser.RawConfigParser()
config_file.read("/etc/zulip/zulip.conf")
states = {
"OK": 0,
"WARNING": 1,
"CRITICAL": 2,
"UNKNOWN": 3,
}
MAXSTATE = 0
def report(state: str, msg: str) -> None:
global MAXSTATE
print(f"{state}: {msg}")
MAXSTATE = max(MAXSTATE, states[state])
def run_sql_query(query: str) -> list[list[str]]:
command = [
"psql",
"-t", # Omit header line
"-A", # Don't pad with spaces
"-z", # Separate columns with nulls
"-v",
"ON_ERROR_STOP=1",
"-d",
get_config(config_file, "postgresql", "database_name", "zulip"),
# No -U; nagios connects as itself
"-c",
f"SELECT {query}",
]
try:
output = subprocess.check_output(command, stderr=subprocess.STDOUT, text=True).strip()
if not output:
return []
return [x.split("\0") for x in output.split("\n")]
except subprocess.CalledProcessError as e:
report("CRITICAL", f"psql failed: {e}: {e.output}")
sys.exit(MAXSTATE)
def loc_to_abs_offset(loc_str: str) -> int:
m = re.match(r"^\s*([0-9a-fA-F]+)/([0-9a-fA-F]+)\s*$", loc_str)
if not m:
raise ValueError("Unknown xlog location format: " + loc_str)
(xlog_file, file_offset) = (m.group(1), m.group(2))
# From PostgreSQL 9.2's pg_xlog_location_diff:
# result = XLogFileSize * (xlogid1 - xlogid2) + xrecoff1 - xrecoff2
# Taking xlogid2 and xrecoff2 to be zero to get the absolute offset:
# result = XLogFileSize * xlogid1 + xrecoff1
#
# xlog_internal.h says:
# #define XLogSegSize ((uint32) XLOG_SEG_SIZE)
# #define XLogSegsPerFile (((uint32) 0xffffffff) / XLogSegSize)
# #define XLogFileSize (XLogSegsPerFile * XLogSegSize)
#
# Since XLOG_SEG_SIZE is normally 16MB, XLogFileSize comes out to 0xFF000000
return 0xFF000000 * int(xlog_file, 16) + int(file_offset, 16)
is_in_recovery = run_sql_query("pg_is_in_recovery()")
if is_in_recovery[0][0] == "t":
replication_info = run_sql_query(
"sender_host, status, pg_last_wal_replay_lsn(), pg_last_wal_receive_lsn()"
" from pg_stat_wal_receiver"
)
if not replication_info:
report("CRITICAL", "Replaying WAL logs from backups")
else:
(primary_server, state, replay_loc, recv_loc) = replication_info[0]
recv_offset = loc_to_abs_offset(recv_loc)
replay_lag = recv_offset - loc_to_abs_offset(replay_loc)
if state != "streaming":
report("CRITICAL", f"replica is in state {state}, not streaming")
msg = f"replica is {replay_lag} bytes behind in replay of WAL logs from {primary_server}"
if replay_lag > 5 * 16 * 1024**2:
report("CRITICAL", msg)
elif replay_lag > 16 * 1024**2:
report("WARNING", msg)
else:
report("OK", msg)
else:
replication_info = run_sql_query(
"client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn from pg_stat_replication"
)
if not replication_info:
report("CRITICAL", "No replicas!")
elif len(replication_info) == 1:
report("WARNING", "Only one replica!")
else:
report("OK", f"Found {len(replication_info)} replicas")
for replica in replication_info:
(client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn) = replica
if state != "streaming":
report("CRITICAL", f"replica {client_addr} is in state {state}, not streaming")
sent_offset = loc_to_abs_offset(sent_lsn)
lag: dict[str, int] = {}
lag["write"] = sent_offset - loc_to_abs_offset(write_lsn)
lag["flush"] = sent_offset - loc_to_abs_offset(flush_lsn)
lag["replay"] = sent_offset - loc_to_abs_offset(replay_lsn)
for lag_type in ("write", "flush", "replay"):
lag_bytes = lag[lag_type]
msg = f"replica {client_addr} is {lag_bytes} bytes behind in {lag_type} of WAL logs"
if lag_bytes > 5 * 16 * 1024**2:
report("CRITICAL", msg)
elif lag_bytes > 16 * 1024**2:
report("WARNING", msg)
else:
report("OK", msg)
sys.exit(MAXSTATE)

View File

@@ -0,0 +1,8 @@
-----BEGIN DH PARAMETERS-----
MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz
+8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a
87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7
YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi
7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD
ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg==
-----END DH PARAMETERS-----

View File

@@ -0,0 +1,25 @@
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param REQUEST_SCHEME $scheme;
uwsgi_param HTTPS $https if_not_empty;
uwsgi_param REMOTE_ADDR $trusted_remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_ADDR $server_addr;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;
uwsgi_param HTTP_X_REAL_IP $trusted_remote_addr;
uwsgi_param HTTP_X_FORWARDED_PROTO $trusted_x_forwarded_proto;
uwsgi_param HTTP_X_FORWARDED_SSL "";
uwsgi_param HTTP_X_PROXY_MISCONFIGURATION $x_proxy_misconfiguration;
# This value is the default, and is provided for explicitness; it must
# be longer than the configured 55s "harakiri" timeout in uwsgi
uwsgi_read_timeout 60s;
uwsgi_pass django;

View File

@@ -0,0 +1,11 @@
# Proxies /external_content to a local installation of the camo image
# proxy software. Because camo serves its metrics at /metrics,
# explicitly 404 that to external requests.
location /external_content/metrics {
return 404;
}
location /external_content {
rewrite /external_content/(.*) /$1 break;
proxy_pass http://camo;
include /etc/nginx/zulip-include/proxy;
}

View File

@@ -0,0 +1,8 @@
# Use this when you have a server listening on localhost that is serving Zulip
# with some sort of authentication middleware installed. The proxied-to server
# should pass REMOTE_USER to /accounts/login/sso/ once it has satisfactorily
# identified the user.
location /accounts/login/sso/ {
proxy_pass https://localhost_sso;
include /etc/nginx/zulip-include/proxy;
}

View File

@@ -0,0 +1,17 @@
# This file should be used if the nginx is behind a dedicated
# loadbalancer. This allows nginx to be configured for long
# keep-alive timeouts, to keep connections open to the loadbalancer.
#
# All timeouts here should be _longer_ than those on the loadbalancer.
# This ensures that there cannot be a race condition between nginx
# deciding to shut down the connection, and the load-balancer sending
# a request, resulting in a 502 from the loadbalancer.
#
# The timeouts on the loadbalancer are assumed to be 20min (1200s) or
# less; these timeouts are hence for 21min.
keepalive_timeout 1260;
client_header_timeout 1260;
client_body_timeout 1260;
keepalive_requests 500;

View File

@@ -0,0 +1,4 @@
include /etc/nginx/zulip-include/headers;
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Headers Authorization always;
add_header Access-Control-Allow-Methods 'GET, POST, DELETE, PUT, PATCH, HEAD' always;

View File

@@ -0,0 +1,4 @@
# Directory needed for certbot --webroot to work.
location /.well-known/acme-challenge/ {
alias /var/lib/zulip/certbot-webroot/.well-known/acme-challenge/;
}

View File

@@ -0,0 +1,10 @@
# Enable HSTS: tell browsers to always use HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload" always;
# Set X-Frame-Options to deny to prevent clickjacking
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Cross-Origin-Opener-Policy same-origin always;

View File

@@ -0,0 +1,4 @@
include /etc/nginx/zulip-include/headers;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin *;
add_header Timing-Allow-Origin *;

View File

@@ -0,0 +1,11 @@
proxy_http_version 1.1;
# Clearing the Connection header is required for keepalives from the load balancer
# http://nginx.org/en/docs/http/ngx_http_upstream_module.html#keepalive
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $trusted_x_forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-Ip $trusted_remote_addr;
proxy_set_header X-Proxy-Misconfiguration $x_proxy_misconfiguration;
proxy_next_upstream off;
proxy_redirect off;

View File

@@ -0,0 +1,3 @@
include /etc/nginx/zulip-include/proxy;
proxy_buffering off;
proxy_read_timeout 1200;

View File

@@ -0,0 +1,4 @@
include /etc/nginx/zulip-include/headers;
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Headers Authorization always;
add_header Access-Control-Allow-Methods 'OPTIONS, GET, DELETE' always;

View File

@@ -0,0 +1,165 @@
access_log /var/log/nginx/access.log combined_with_host_and_time;
error_log /var/log/nginx/error.log;
include /etc/nginx/zulip-include/headers;
# Serve a custom error page when the app is down
error_page 502 503 504 /static/webpack-bundles/5xx.html;
# Serve static files directly
location /local-static {
alias /home/zulip/local-static;
}
location /static/ {
alias /home/zulip/prod-static/;
gzip_static on;
include /etc/nginx/zulip-include/headers;
add_header Access-Control-Allow-Origin *;
add_header Timing-Allow-Origin *;
# Set a nonexistent path, so we just serve the nice Django 404 page.
error_page 404 /django_static_404.html;
# These files are hashed and thus immutable; cache them aggressively.
# Django adds 12 hex digits; Webpack adds 20.
location ~ '\.[0-9a-f]{12}\.|[./][0-9a-f]{20}\.' {
include /etc/nginx/zulip-include/immutable_headers;
}
}
# Canonical URL for the root is /help/.
location = /help {
return 301 /help/;
}
# Redirect to links without the trailing slash except for root /help/.
location ~ ^(/help(?:/[^/]+)+)/+$ {
return 301 $1$is_args$args;
}
# Serve help center generated by astro/starlight.
location /help {
alias $help_alias;
gzip_static on;
include /etc/nginx/zulip-include/headers;
add_header Access-Control-Allow-Origin *;
add_header Timing-Allow-Origin *;
error_page 404 /404.html;
index /index.html;
try_files $uri $uri/index.html =404;
# These files are hashed and thus immutable; cache them aggressively.
# https://github.com/Pagefind/pagefind/issues/747#issuecomment-2510564644
# https://github.com/withastro/docs/blob/53603ad048e8aedbca1aed77bac8eb00dcada79d/src/content/docs/en/guides/integrations-guide/node.mdx?plain=1#L304
location ~ ^/help/(pagefind/.*\.(?:pf_fragment|pf_index|pf_meta)|_astro/.*)$ {
include /etc/nginx/zulip-include/immutable_headers;
}
}
# Send longpoll requests to Tornado
location = /json/events {
if ($request_method = 'OPTIONS') {
# add_header does not propagate into/out of blocks, so this
# include cannot be factored out
include /etc/nginx/zulip-include/headers;
add_header Allow 'OPTIONS, GET, DELETE' always;
return 204;
}
if ($request_method !~ ^(GET|DELETE)$ ) {
# add_header does not propagate into/out of blocks, so this
# include cannot be factored out
include /etc/nginx/zulip-include/headers;
add_header Allow 'OPTIONS, GET, DELETE' always;
return 405;
}
proxy_pass $tornado_server;
include /etc/nginx/zulip-include/proxy_longpolling;
}
# Send longpoll requests to Tornado
location = /api/v1/events {
if ($request_method = 'OPTIONS') {
include /etc/nginx/zulip-include/tornado_cors_headers;
add_header Allow 'OPTIONS, GET, DELETE' always;
return 204;
}
if ($request_method !~ ^(GET|DELETE)$ ) {
include /etc/nginx/zulip-include/headers;
add_header Allow 'OPTIONS, GET, DELETE' always;
return 405;
}
include /etc/nginx/zulip-include/tornado_cors_headers;
proxy_pass $tornado_server;
include /etc/nginx/zulip-include/proxy_longpolling;
}
# Handle X-Accel-Redirect from Tornado to Tornado
location ~ ^/internal/tornado/(\d+)(/.*)$ {
internal;
proxy_pass http://tornado$1$2$is_args$args;
include /etc/nginx/zulip-include/proxy_longpolling;
}
location /api/v1/tus {
include /etc/nginx/zulip-include/api_headers;
include /etc/nginx/zulip-include/proxy;
# https://github.com/tus/tusd/blob/main/examples/nginx.conf
# Disable request body size limits, and stream the request and
# response from tusd directly.
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
proxy_pass http://tusd;
}
# Send everything else to Django via uWSGI
location / {
include uwsgi_params;
}
# These Django routes not under /api are shared between mobile and
# web, and thus need API headers added. We can't easily collapse
# these blocks with the /api block, because regular expressions take
# priority over paths in nginx's order-of-operations, and we don't
# want to override the tornado configuration for /api/v1/events.
location /thumbnail {
include /etc/nginx/zulip-include/api_headers;
include uwsgi_params;
}
location /avatar {
include /etc/nginx/zulip-include/api_headers;
include uwsgi_params;
}
location /user_uploads {
include /etc/nginx/zulip-include/api_headers;
include uwsgi_params;
}
location /api/internal/ {
# These only need be accessed from localhost
allow 127.0.0.1;
allow ::1;
deny all;
include /etc/nginx/zulip-include/api_headers;
include uwsgi_params;
}
# Send all API routes not covered above to Django via uWSGI
location /api/ {
include /etc/nginx/zulip-include/api_headers;
include uwsgi_params;
}
include /etc/nginx/zulip-include/app.d/*.conf;

View File

@@ -0,0 +1,88 @@
# Handle redirects to S3
location ~ ^/internal/s3/(?<s3_hostname>[^/]+)/(?<s3_path>.*) {
internal;
include /etc/nginx/zulip-include/headers;
add_header Content-Security-Policy "default-src 'none'; media-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; object-src 'self'; plugin-types application/pdf;";
# The components of this path are originally double-URI-escaped
# (see zerver/view/upload.py). "location" matches are on
# unescaped values, which fills $s3_path with a properly
# single-escaped path to pass to the upstream server.
# (see associated commit message for more details)
set $download_url https://$s3_hostname/$s3_path;
proxy_set_header Host $s3_hostname;
proxy_ssl_name $s3_hostname;
proxy_ssl_server_name on;
# Strip off X-amz-cf-id header, which otherwise the request has to
# have been signed over, leading to signature mismatches.
proxy_set_header x-amz-cf-id "";
# Strip off any auth request headers which the Zulip client might
# have sent, as they will not work for S3, and will report an error due
# to the signed auth header we also provide.
proxy_set_header Authorization "";
proxy_set_header x-amz-security-token "";
# These headers are only valid if there is a body, but better to
# strip them to be safe.
proxy_set_header Content-Length "";
proxy_set_header Content-Type "";
proxy_set_header Content-MD5 "";
proxy_set_header x-amz-content-sha256 "";
proxy_set_header Expect "";
# Ensure that we only get _one_ of these response headers: the one
# that Django added, not the one from S3.
proxy_hide_header Cache-Control;
proxy_hide_header Expires;
proxy_hide_header Set-Cookie;
# We are _leaving_ S3 to provide Content-Type,
# Content-Disposition, and Accept-Ranges headers, which are the
# three remaining headers which nginx would also pass through from
# the first response. Django explicitly unsets the first, and
# does not set the latter two.
# We slice the content into 5M chunks; this means that the client
# doesn't need to wait for nginx to download and cache the full
# content if the client just requested a small range (e.g. for
# showing a thumbnail of a large video). 5M is chosen to be
# enough for videos to be able to thumbnail in one slice, but not
# take overly long to retrieve from S3, or cause overwhelming
# numbers of cache entries for large files.
slice 5m;
proxy_set_header Range $slice_range;
proxy_pass $download_url$is_args$args;
proxy_cache uploads;
# If the S3 response doesn't contain Cache-Control headers (which
# we don't expect it to) then we assume they are valid for a very
# long time. The size of the cache is controlled by
# `s3_disk_cache_size` and read frequency, set via
# `s3_cache_inactive_time`.
proxy_cache_valid 200 206 1y;
# We only include the requested content-disposition (and range
# slice) in the cache key, so that we cache "Content-Disposition:
# attachment" separately from the inline version.
proxy_cache_key $download_url$s3_disposition_cache_key$slice_range;
}
# Internal file-serving
location /internal/local/uploads {
internal;
include /etc/nginx/zulip-include/headers;
add_header Content-Security-Policy "default-src 'none'; media-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; object-src 'self'; plugin-types application/pdf;";
# Django handles setting Content-Type, Content-Disposition, and Cache-Control.
alias /home/zulip/uploads/files;
}
location /internal/local/user_avatars {
internal;
include /etc/nginx/zulip-include/headers;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'";
include /etc/nginx/zulip-include/uploads.types;
alias /home/zulip/uploads/avatars;
}

View File

@@ -0,0 +1,23 @@
types {
text/plain txt;
application/pdf pdf;
image/gif gif;
image/jpeg jpeg jpg jpe;
image/png png;
image/tiff tif tiff;
image/webp webp;
video/3gpp 3gpp 3gp;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}

View File

@@ -0,0 +1,17 @@
include /etc/nginx/zulip-include/tornado-upstreams;
upstream django {
server unix:/home/zulip/deployments/uwsgi-socket;
}
upstream localhost_sso {
server 127.0.0.1:8888;
}
upstream camo {
server 127.0.0.1:9292;
}
upstream tusd {
server 127.0.0.1:9900;
}

View File

@@ -0,0 +1,89 @@
# PostgreSQL Client Authentication Configuration File
# ===================================================
#
# Refer to the "Client Authentication" section in the PostgreSQL
# documentation for a complete description of this file. A short
# synopsis follows.
#
# This file controls: which hosts are allowed to connect, how clients
# are authenticated, which PostgreSQL user names they can use, which
# databases they can access. Records take one of these forms:
#
# local DATABASE USER METHOD [OPTIONS]
# host DATABASE USER ADDRESS METHOD [OPTIONS]
# hostssl DATABASE USER ADDRESS METHOD [OPTIONS]
# hostnossl DATABASE USER ADDRESS METHOD [OPTIONS]
#
# (The uppercase items must be replaced by actual values.)
#
# The first field is the connection type: "local" is a Unix-domain
# socket, "host" is either a plain or SSL-encrypted TCP/IP socket,
# "hostssl" is an SSL-encrypted TCP/IP socket, and "hostnossl" is a
# plain TCP/IP socket.
#
# DATABASE can be "all", "sameuser", "samerole", "replication", a
# database name, or a comma-separated list thereof. The "all"
# keyword does not match "replication". Access to replication
# must be enabled in a separate record (see example below).
#
# USER can be "all", a user name, a group name prefixed with "+", or a
# comma-separated list thereof. In both the DATABASE and USER fields
# you can also write a file name prefixed with "@" to include names
# from a separate file.
#
# ADDRESS specifies the set of hosts the record matches. It can be a
# host name, or it is made up of an IP address and a CIDR mask that is
# an integer (between 0 and 32 (IPv4) or 128 (IPv6) inclusive) that
# specifies the number of significant bits in the mask. A host name
# that starts with a dot (.) matches a suffix of the actual host name.
# Alternatively, you can write an IP address and netmask in separate
# columns to specify the set of hosts. Instead of a CIDR-address, you
# can write "samehost" to match any of the server's own IP addresses,
# or "samenet" to match any address in any subnet that the server is
# directly connected to.
#
# METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
# Note that "password" sends passwords in clear text; "md5" or
# "scram-sha-256" are preferred since they send encrypted passwords.
#
# OPTIONS are a set of options for the authentication in the format
# NAME=VALUE. The available options depend on the different
# authentication methods -- refer to the "Client Authentication"
# section in the documentation for a list of which options are
# available for which authentication methods.
#
# Database and user names containing spaces, commas, quotes and other
# special characters must be quoted. Quoting one of the keywords
# "all", "sameuser", "samerole" or "replication" makes the name lose
# its special character, and just match a database or username with
# that name.
#
# This file is read on server startup and when the server receives a
# SIGHUP signal. If you edit the file on a running system, you have to
# SIGHUP the server for the changes to take effect, run "pg_ctl reload",
# or execute "SELECT pg_reload_conf()".
#
# Put your actual configuration here
# ----------------------------------
#
# If you want to allow non-local connections, you need to add more
# "host" records. In that case you will also need to make PostgreSQL
# listen on a non-local interface via the listen_addresses
# configuration parameter, or via the -i or -h command line switches.
# TYPE DATABASE USER ADDRESS METHOD
# "local" is for Unix domain socket connections only
local all all peer
# IPv4 local connections:
host all all 127.0.0.1/32 md5
# IPv6 local connections:
host all all ::1/128 md5
# Allow replication connections from localhost, by a user with the
# replication privilege.
local replication all peer
host replication all 127.0.0.1/32 ident
host replication all ::1/128 ident

View File

@@ -0,0 +1,51 @@
#!/bin/sh
if [ -z "$ZULIP_SECRETS_CONF" ]; then
ZULIP_SECRETS_CONF=/etc/zulip/zulip-secrets.conf
fi
export PGHOST=/var/run/postgresql/
WALG_DELTA_MAX_STEPS=$(crudini --get /etc/zulip/zulip.conf postgresql backups_incremental 2>/dev/null)
export WALG_DELTA_MAX_STEPS
WALG_COMPRESSION_METHOD=$(crudini --get /etc/zulip/zulip.conf postgresql backups_compression_method 2>/dev/null)
export WALG_COMPRESSION_METHOD
s3_backups_bucket=$(crudini --get "$ZULIP_SECRETS_CONF" secrets s3_backups_bucket 2>/dev/null)
if [ "$s3_backups_bucket" != "" ] || [ -n "$WALG_S3_PREFIX" ]; then
AWS_REGION=$(crudini --get "$ZULIP_SECRETS_CONF" secrets s3_region 2>/dev/null)
if [ "$AWS_REGION" = "" ]; then
# Fall back to the current region, if possible
AZ=$(ec2metadata --availability-zone || true)
if [ -n "$AZ" ] && [ "$AZ" != "unavailable" ]; then
AWS_REGION=$(echo "$AZ" | sed 's/.$//')
fi
fi
export AWS_REGION
AWS_ACCESS_KEY_ID=$(crudini --get "$ZULIP_SECRETS_CONF" secrets s3_backups_key 2>/dev/null)
export AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=$(crudini --get "$ZULIP_SECRETS_CONF" secrets s3_backups_secret_key 2>/dev/null)
export AWS_SECRET_ACCESS_KEY
: "${WALG_S3_PREFIX:=s3://$s3_backups_bucket}"
export WALG_S3_PREFIX
if storage_class=$(crudini --get /etc/zulip/zulip.conf postgresql backups_storage_class 2>/dev/null); then
export WALG_S3_STORAGE_CLASS="$storage_class"
fi
else
WALG_FILE_PREFIX=$(crudini --get /etc/zulip/zulip.conf postgresql backups_directory 2>/dev/null)
if [ "$WALG_FILE_PREFIX" != "" ]; then
export WALG_FILE_PREFIX
if [ "$WALG_DELTA_MAX_STEPS" = "" ]; then
# Default to only taking a full backup every week
export WALG_DELTA_MAX_STEPS=6
fi
else
echo "Could not determine where to back up data to!"
exit 1
fi
fi
exec /usr/local/bin/wal-g "$@"

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
import configparser
import glob
import json
import logging
import os
import subprocess
import sys
import time
from datetime import datetime, timedelta, timezone
logging.Formatter.converter = time.gmtime
logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
config_file = configparser.RawConfigParser()
config_file.read("/etc/zulip/zulip.conf")
def get_config(
section: str,
key: str,
default_value: str = "",
) -> str:
if config_file.has_option(section, key):
return config_file.get(section, key)
return default_value
replicas = subprocess.check_output(
["psql", "-v", "ON_ERROR_STOP=1", "-t", "-c", "SELECT COUNT(*) FROM pg_stat_replication"],
stdin=subprocess.DEVNULL,
text=True,
).strip()
if int(replicas) > 0:
# We are the primary and we have replicas; we expect that backups
# will be taken on one of them.
sys.exit(0)
skip_backups = get_config("postgresql", "skip_backups", "")
if skip_backups.lower() in [1, "y", "t", "yes", "true", "enable", "enabled"]:
sys.exit(0)
is_rhel_based = os.path.exists("/etc/redhat-release")
if is_rhel_based:
pg_data_paths = glob.glob("/var/lib/pgsql/*/data")
else:
pg_data_paths = glob.glob("/var/lib/postgresql/*/main")
if len(pg_data_paths) != 1:
print(f"PostgreSQL installation is not unique: {pg_data_paths}")
sys.exit(1)
pg_data_path = pg_data_paths[0]
disk_concurrency = get_config("postgresql", "backups_disk_concurrency", "1")
env = os.environ.copy()
env["WALG_UPLOAD_DISK_CONCURRENCY"] = disk_concurrency
subprocess.check_call(["env-wal-g", "backup-push", pg_data_path], env=env)
data = json.loads(subprocess.check_output(["env-wal-g", "backup-list", "--json"]))
# Use strptime for Python < 3.11 compatibility; we might not have
# backports-datetime-fromisoformat here
backups = {
datetime.strptime(backup["time"], "%Y-%m-%dT%H:%M:%S%z"): backup["backup_name"]
for backup in data
}
one_month_ago = datetime.now(tz=timezone.utc) - timedelta(days=30)
for date in sorted(backups.keys(), reverse=True):
if date < one_month_ago:
# We pass `FIND_FULL` such that if delta backups are being
# used, we keep the prior FULL backup and all of the deltas
# that we need. In practice, this means that if we're doing
# weekly full backups (`backups_incremental = 6`), we only
# delete any data once a week.
subprocess.check_call(
["env-wal-g", "delete", "--confirm", "before", "FIND_FULL", backups[date]]
)
# Because we're going from most recent to least recent, we
# only have to do one delete operation
break

View File

@@ -0,0 +1,8 @@
# This file is managed by Zulip's Puppet configuration.
#
# Linux 6.14+ charges (per-user) the rings from io_uring against to
# RLIMIT_MEMLOCK, which is by default limited to 8MiB; the default
# Zulip PostgreSQL configuration needs ~13MiB. Set a limit sizably
# above that.
[Service]
LimitMEMLOCK=256M

View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
# Processes updates to PostgreSQL full-text search for new/edited messages.
#
# Zulip manages its PostgreSQL full-text search as follows. When the
# content of a message is modified, a PostgreSQL trigger logs the
# message ID to the `fts_update_log` table. In the background, this
# program processes `fts_update_log`, updating the PostgreSQL full-text
# search column search_tsvector in the main zerver_message.
#
# There are four cases this has to cover:
#
# 1. Running in development, with a venv but no
# /home/zulip/deployments/current nor /etc/zulip/zulip.conf
#
# 2. Running in production, with postgresql on the same machine as the
# frontend, with a venv and both /home/zulip/deployments/current
# and /etc/zulip/zulip.conf
#
# 3. Running in production, on a postgresql machine which is not the
# frontend, with a /etc/zulip/zulip.conf but no venv nor
# /home/zulip/deployments/current
#
# 4. Running in production, on an application frontend server connected
# to a remote postgresql server, because we cannot run code _on_ the
# PostgreSQL server, such as in docker-zulip.
#
# Because of case (3), we cannot rely on functions from outside this
# file (e.g. provided by scripts.lib.zulip_tools). For case (1),
# however, we wish to import `zerver.settings` since we have no
# /etc/zulip/zulip.conf to read, and that case _requires_ loading the
# venv.
#
# We also must handle the cases where we are run as the `nagios` user,
# which may not have permission to read all configuration files, and
# thus (2) will look like (3).
import argparse
import configparser
import logging
import os
import select
import sys
import time
from collections.abc import Sequence
import psycopg2
import psycopg2.extensions
from psycopg2.sql import SQL
BATCH_SIZE = 1000
parser = argparse.ArgumentParser()
parser.add_argument("--quiet", action="store_true")
parser.add_argument("--nagios-check", action="store_true")
parser.add_argument("--nagios-user")
options = parser.parse_args()
logging.Formatter.converter = time.gmtime
logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s")
logger = logging.getLogger("process_fts_updates")
if options.quiet:
logger.setLevel(logging.INFO)
else:
logger.setLevel(logging.DEBUG)
def update_fts_columns(conn: psycopg2.extensions.connection) -> int:
with conn.cursor() as cursor:
cursor.execute(
"SELECT id, message_id FROM fts_update_log ORDER BY id LIMIT %s FOR UPDATE SKIP LOCKED",
(BATCH_SIZE,),
)
cursor.arraysize = BATCH_SIZE
parts = list(zip(*cursor.fetchmany(), strict=True))
if not parts:
row_ids: Sequence[int] = []
message_ids: Sequence[int] = []
else:
row_ids, message_ids = parts[0], parts[1]
if message_ids:
if USING_PGROONGA:
update_sql = SQL(
"search_pgroonga = escape_html(subject) || ' ' || rendered_content"
)
else:
update_sql = SQL(
"search_tsvector = to_tsvector('zulip.english_us_search', subject || rendered_content)"
)
cursor.execute(
SQL(
"UPDATE zerver_message SET {update_sql} "
"WHERE ctid IN ("
" SELECT ctid FROM zerver_message"
" WHERE id IN %s"
" ORDER BY id FOR UPDATE"
")"
).format(update_sql=update_sql),
(message_ids,),
)
if row_ids:
cursor.execute("DELETE FROM fts_update_log WHERE id IN %s", (row_ids,))
conn.commit()
return len(row_ids)
def update_all_rows(msg: str, conn: psycopg2.extensions.connection) -> None:
while True:
start_time = time.perf_counter()
rows_updated = update_fts_columns(conn)
if rows_updated:
logger.log(
logging.INFO,
"process_fts_updates: %s %d rows, %d rows/sec",
msg,
rows_updated,
rows_updated / (time.perf_counter() - start_time),
)
if rows_updated != BATCH_SIZE:
return
def am_master(conn: psycopg2.extensions.connection) -> bool:
with conn.cursor() as cursor:
cursor.execute("SELECT pg_is_in_recovery()")
return not cursor.fetchall()[0][0]
def get_config(
config_file: configparser.RawConfigParser,
section: str,
key: str,
default_value: str = "",
) -> str:
if config_file.has_option(section, key):
return config_file.get(section, key)
return default_value
pg_args = {}
USING_PGROONGA = False
try:
# Case (1); we insert the path to the development root.
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..")))
# Cases (2) and (4); we insert the path to the production root.
# This likely works out the same as the above path.
#
# We insert this path after the above, so that if running this
# command from a specific non-current Zulip deployment, we prefer
# that deployment's libraries.
sys.path.insert(1, "/home/zulip/deployments/current")
# For cases (2) and (4), we also need to set up the virtualenv, so we
# can read the Django settings.
from scripts.lib.setup_path import setup_path
setup_path()
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
from django.conf import settings
pg_args["host"] = settings.DATABASES["default"]["HOST"]
pg_args["port"] = settings.DATABASES["default"].get("PORT")
pg_args["password"] = settings.DATABASES["default"].get("PASSWORD")
pg_args["user"] = settings.DATABASES["default"]["USER"]
pg_args["dbname"] = settings.DATABASES["default"]["NAME"]
pg_args["sslmode"] = settings.DATABASES["default"]["OPTIONS"].get("sslmode")
pg_args["connect_timeout"] = "600"
USING_PGROONGA = settings.USING_PGROONGA
except (ImportError, PermissionError):
# Case (3): we know that the PostgreSQL server is on this host.
pg_args["user"] = "zulip"
config_file = configparser.RawConfigParser()
config_file.read("/etc/zulip/zulip.conf")
if get_config(config_file, "machine", "pgroonga", "false").lower() in [
"1",
"y",
"t",
"yes",
"true",
"enable",
"enabled",
]:
USING_PGROONGA = True
pg_args["user"] = get_config(config_file, "postgresql", "database_user", "zulip")
if pg_args["user"] != "zulip":
secrets_file = configparser.RawConfigParser()
secrets_file.read("/etc/zulip/zulip-secrets.conf")
pg_args["password"] = get_config(secrets_file, "secrets", "postgres_password")
pg_args["host"] = "localhost"
pg_args["dbname"] = get_config(config_file, "postgresql", "database_name", "zulip")
conn: psycopg2.extensions.connection | None
if options.nagios_check:
# Nagios connects as itself, unless you specify otherwise
if options.nagios_user:
pg_args["user"] = options.nagios_user
else:
del pg_args["user"]
# connection_factory=None lets mypy understand the return type
conn = psycopg2.connect(connection_factory=None, **pg_args)
cursor = conn.cursor()
cursor.execute("SELECT count(*) FROM fts_update_log")
num = cursor.fetchall()[0][0]
# nagios exit codes
states = {
"OK": 0,
"WARNING": 1,
"CRITICAL": 2,
"UNKNOWN": 3,
}
state = "OK"
if num > 5:
state = "CRITICAL"
print(f"{state}: {num} rows in fts_update_log table")
sys.exit(states[state])
conn = None
retries = 1
while True:
try:
if conn is None:
# connection_factory=None lets mypy understand the return type
conn = psycopg2.connect(connection_factory=None, **pg_args)
retries = 30
conn.autocommit = False
conn.isolation_level = psycopg2.extensions.ISOLATION_LEVEL_READ_COMMITTED
first_check = True
while not am_master(conn):
if first_check:
first_check = False
logger.warning("In recovery; sleeping")
time.sleep(5)
logger.debug("process_fts_updates: listening for search index updates")
with conn.cursor() as cursor:
cursor.execute("LISTEN fts_update_log;")
conn.commit()
# Catch up on any historical columns
update_all_rows("Caught up", conn)
if select.select([conn], [], [], 30) != ([], [], []):
conn.poll()
conn.notifies.clear()
update_all_rows("Updated", conn)
except psycopg2.OperationalError as e:
# We will end up here if the database is restarted, or becomes read-only.
retries -= 1
if retries <= 0:
raise
logger.info(e.pgerror, exc_info=True)
logger.info("Sleeping and reconnecting")
time.sleep(5)
if conn is not None:
conn.close()
conn = None
except KeyboardInterrupt:
print(sys.argv[0], "exited after receiving KeyboardInterrupt")
break

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
import configparser
import contextlib
import json
import logging
import os
import subprocess
import sys
from collections import defaultdict
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Protocol
from urllib.parse import parse_qs, urlsplit
class GaugeMetric(Protocol):
def __call__(self, value: float, labels: Mapping[str, str] | None = None, /) -> None:
pass
class WalGPrometheusServer(BaseHTTPRequestHandler):
METRIC_PREFIX = "wal_g_backup_"
metrics: dict[str, list[str]] = {}
metric_values: dict[str, dict[str, str]] = defaultdict(dict)
server_version = "wal-g-prometheus-server/1.0"
def gauge(
self, name: str, description: str | None = None, default_value: float | None = None
) -> GaugeMetric:
if name in self.metrics:
raise ValueError(f"Redefinition of {name} metric")
self.metrics[name] = [f"# TYPE {self.METRIC_PREFIX}{name} gauge"]
if description is not None:
self.metrics[name].append(f"# HELP {self.METRIC_PREFIX}{name} {description}")
def inner(value: float, labels: Mapping[str, str] | None = None) -> None:
label_str = ""
if labels:
label_str = "{" + ",".join(f'{k}="{v}"' for k, v in labels.items()) + "}"
self.metric_values[name][label_str] = f"{self.METRIC_PREFIX}{name}{label_str} {value}"
if default_value is not None:
inner(default_value)
return inner
def print_metrics(self) -> None:
lines = []
for metric_name in self.metrics:
if self.metric_values[metric_name]:
# Print preamble
lines += self.metrics[metric_name]
lines += self.metric_values[metric_name].values()
lines.append("")
self.wfile.write("\n".join(lines).encode())
def do_GET(self) -> None:
if urlsplit(self.path).path != "/metrics":
self.send_response(404)
self.end_headers()
sys.stderr.flush()
return
self.send_response(200)
self.send_header("Content-type", "text/plain; version=0.0.4")
self.end_headers()
self.metrics = {}
self.metric_values = defaultdict(dict)
backup_ok = self.gauge("ok", "If wal-g backup-list was OK", 0)
backup_count = self.gauge("count", "Number of backups")
backup_earliest_age_seconds = self.gauge("earliest_age_seconds", "Age of the oldest backup")
backup_latest_age_seconds = self.gauge(
"latest_age_seconds", "Age of the most recent backup"
)
backup_latest_duration_seconds = self.gauge(
"latest_duration_seconds", "Duration the most recent backup took, in seconds"
)
backup_latest_compressed_size_bytes = self.gauge(
"latest_compressed_size_bytes", "Size of the most recent backup, in bytes"
)
backup_latest_uncompressed_size_bytes = self.gauge(
"latest_uncompressed_size_bytes",
"Uncompressed size of the most recent backup, in bytes",
)
backup_total_compressed_size_bytes = self.gauge(
"total_compressed_size_bytes", "Total compressed size of all backups, in bytes"
)
now = datetime.now(tz=timezone.utc)
try:
query = parse_qs(urlsplit(self.path).query)
modified_env = os.environ.copy()
if query.get("bucket") and len(query["bucket"]) == 1:
bucket = query["bucket"][0]
modified_env["WALG_S3_PREFIX"] = f"s3://{bucket}"
else:
config_file = configparser.RawConfigParser()
config_file.read("/etc/zulip/zulip-secrets.conf")
bucket = config_file["secrets"]["s3_backups_bucket"]
backup_list_output = subprocess.check_output(
["env-wal-g", "backup-list", "--detail", "--json"],
text=True,
env=modified_env,
)
data = json.loads(backup_list_output) if backup_list_output else []
backup_count(len(data), {"bucket": bucket})
backup_total_compressed_size_bytes(
sum(e["compressed_size"] for e in data), {"bucket": bucket}
)
if len(data) > 0:
data.sort(key=lambda e: e["start_time"], reverse=True)
latest = data[0]
labels = {
"host": latest["hostname"],
"pg_version": str(latest["pg_version"]),
"bucket": bucket,
}
backup_latest_compressed_size_bytes(latest["compressed_size"], labels)
backup_latest_uncompressed_size_bytes(latest["uncompressed_size"], labels)
def t(key: str, e: dict[str, str] = latest) -> datetime:
return datetime.strptime(e[key], e["date_fmt"]).replace(tzinfo=timezone.utc)
backup_earliest_age_seconds(
(now - t("start_time", data[-1])) / timedelta(seconds=1),
{
"host": data[-1]["hostname"],
"pg_version": data[-1]["pg_version"],
"bucket": bucket,
},
)
backup_latest_age_seconds((now - t("start_time")) / timedelta(seconds=1), labels)
backup_latest_duration_seconds(
(t("finish_time") - t("start_time")) / timedelta(seconds=1), labels
)
backup_ok(1)
except Exception:
logging.exception("Error while getting backup information")
finally:
self.print_metrics()
self.log_message(
"Served in %.2f seconds",
(datetime.now(tz=timezone.utc) - now) / timedelta(seconds=1),
)
sys.stderr.flush()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
server = HTTPServer(("127.0.0.1", 9188), WalGPrometheusServer)
logging.info("Starting server...")
with contextlib.suppress(KeyboardInterrupt):
server.serve_forever()
server.server_close()
logging.info("Stopping server...")

View File

@@ -0,0 +1,149 @@
i
me
my
myself
we
our
ours
ourselves
you
your
yours
yourself
yourselves
he
him
his
himself
she
her
hers
herself
it
its
itself
they
them
their
theirs
themselves
what
which
who
whom
this
that
these
those
am
is
are
was
were
be
been
being
have
has
had
having
do
does
did
doing
a
an
the
and
but
if
or
because
as
until
while
of
at
by
for
with
about
against
between
into
through
during
before
after
above
below
to
from
up
down
in
out
on
off
over
under
again
further
then
once
here
there
when
where
why
how
all
any
both
each
few
more
most
other
some
such
no
nor
not
only
own
same
so
than
too
very
s
t
can
will
just
don
should
now
b
c
d
e
f
g
h
j
k
l
m
n
o
p
q
r
u
v
w
x
y
z

View File

@@ -0,0 +1,15 @@
# Defaults to rabbit. This can be useful if you want to run more than one node
# per machine - RABBITMQ_NODENAME should be unique per erlang-node-and-machine
# combination. See the clustering on a single machine guide for details:
# http://www.rabbitmq.com/clustering.html#single-machine
#
# By default, we set nodename to zulip@localhost so it will always resolve
NODENAME=zulip@localhost
# By default RabbitMQ will bind to all interfaces, on IPv4 and IPv6 if
# available. Set this if you only want to bind to one network interface or#
# address family.
#NODE_IP_ADDRESS=127.0.0.1
# Defaults to 5672.
#NODE_PORT=5672

View File

@@ -0,0 +1,9 @@
[{kernel, [{inet_dist_use_interface, {127,0,0,1}}]},
{rabbit, [{tcp_listeners, [{"127.0.0.1", 5672}]},
%% We disable consumer_timeout. Our queue workers ACK an
%% event only after consume() finishes; consumer_timeout doesn't
%% abort a slow job, it just requeues it for a redundant,
%% duplicate run. This mechanism doesn't provide anything helpful
%% for us.
{consumer_timeout, undefined}]},
{rabbitmq_prometheus, [{tcp_config, [{ip, {127,0,0,1}}, {port, 15692}]}]}].

View File

@@ -0,0 +1,2 @@
mech_list: plain
sasldb_path: /etc/sasl2/memcached-sasldb2

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -eu
for arg in "$@"; do
if [ "$arg" == "--" ]; then
shift
exec "$@"
elif [[ "$arg" == *"="* ]]; then
shift
varname="${arg%%=*}"
secretname="${arg#*=}"
secret=$(crudini --get /etc/zulip/zulip-secrets.conf secrets "$secretname")
export "$varname"="$secret"
else
exec "$@"
fi
done
{
echo "Usage:"
echo " secret-env-wrapper ENVNAME=secretname binary [argument [argument [...]]]"
} >&2
exit 1

View File

@@ -0,0 +1,6 @@
; Used in docker.pp, starts cron (for docker use only)
[program:cron]
command = /usr/sbin/cron -f -L 15
stdout_events_enabled=true
stderr_events_enabled=true
autorestart = true

View File

@@ -0,0 +1,6 @@
; Used in docker.pp, starts nginx web server (for docker use only)
[program:nginx]
command = /usr/sbin/nginx -g 'daemon off;'
stdout_events_enabled=true
stderr_events_enabled=true
autorestart = true

View File

@@ -0,0 +1,10 @@
[program:process-fts-updates]
command=/usr/local/bin/process_fts_updates
priority=600 ; the relative start priority (default 999)
autostart=true ; start at supervisord start (default: true)
autorestart=true ; whether/when to restart (default: unexpected)
stopsignal=TERM ; signal used to kill process (default TERM)
stopwaitsecs=30 ; max num secs to wait b4 SIGKILL (default 10)
user=zulip ; setuid to this UNIX account to run the program
redirect_stderr=true ; redirect proc stderr to stdout (default false)
stdout_logfile=/var/log/zulip/fts-updates.log ; stdout log path, NONE for none; default AUTO

View File

@@ -0,0 +1,2 @@
[Manager]
DefaultLimitNOFILE=1000000