Files
webtrees-docker/docker-entrypoint.py

603 lines
18 KiB
Python

import filecmp
import os
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
from dataclasses import dataclass
from enum import Enum
from typing import Any, List, Literal, Optional, TypeVar, Union, overload
from urllib import request
from urllib.parse import urlencode
class NoRedirect(request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class DBType(Enum):
mysql = "mysql"
pgsql = "pgsql"
sqlite = "sqlite"
@dataclass
class EnvVars:
prettyurls: bool
https: bool
httpsredirect: bool
sslcertfile: str
sslcertkeyfile: str
lang: str
baseurl: Optional[str]
dbtype: DBType
dbhost: Optional[str]
dbport: str
dbuser: str
dbpass: Optional[str]
dbname: str
tblpfx: str
wtuser: Optional[str]
wtname: Optional[str]
wtpass: Optional[str]
wtemail: Optional[str]
# https://github.com/fisharebest/webtrees/blob/f9a3af650116d75f1a87f454cabff5e9047e43f3/app/Http/Middleware/UseDatabase.php#L71-L82
dbkey: Optional[str]
dbcert: Optional[str]
dbca: Optional[str]
dbverify: bool
# php settings
phpmemorylimit: str
phpmaxexecutiontime: str
phppostmaxsize: str
phpuploadmaxfilesize: str
# user/group ID
puid: str
pgid: str
def truish(value: Optional[str]) -> bool:
"""
Check if a value is close enough to true
"""
if value is None:
return False
return value.lower().strip() in ["true", "yes", "1"]
def print2(msg: Any) -> None:
"""
Print a message to stderr.
"""
print(f"[NV_INIT] {msg}", file=sys.stderr)
T = TypeVar("T")
@overload
def get_environment_variable(
key: str, default: None = None, alternates: Optional[List[str]] = None
) -> Optional[str]: ...
@overload
def get_environment_variable(
key: str, default: T = None, alternates: Optional[List[str]] = None
) -> T: ...
def get_environment_variable(
key: str, default: Optional[T] = None, alternates: Optional[List[str]] = None
) -> Union[Optional[str], T]:
"""
Try to find the value of an environment variable.
"""
key = key.upper()
# try to find variable in env
if key in os.environ:
value = os.environ[key]
print2(f"{key} found in environment variables")
return value
# try to find file version of variable
file_key = f"{key}_FILE"
if file_key in os.environ:
# file name does not exist
if not os.path.isfile(os.environ[file_key]):
print(f"WARNING: {file_key} is not a file: {os.environ[file_key]}")
return None
# read data from file
with open(os.environ[file_key], "r") as f:
value = f.read().strip()
print2(f"{file_key} found in environment variables")
return value
# try to find alternate variable
if alternates is not None:
for a in alternates:
a_value = get_environment_variable(a)
if a_value is not None:
return a_value
# return default value
print2(f"{key} NOT found in environment variables, using default: {default}")
return default
ENV = EnvVars(
prettyurls=truish(get_environment_variable("PRETTY_URLS")),
https=truish(get_environment_variable("HTTPS", alternates=["SSL"])),
httpsredirect=truish(
get_environment_variable("HTTPS_REDIRECT", alternates=["SSL_REDIRECT"])
),
sslcertfile=get_environment_variable("SSL_CERT_FILE", "/certs/webtrees.crt"),
sslcertkeyfile=get_environment_variable("SSL_CERT_KEY_FILE", "/certs/webtrees.key"),
baseurl=get_environment_variable("BASE_URL"),
lang=get_environment_variable("LANG", "en-US"),
dbtype=DBType[get_environment_variable("DB_TYPE", "mysql")],
dbhost=get_environment_variable("DB_HOST"),
dbport=get_environment_variable("DB_PORT", "3306"),
dbuser=get_environment_variable(
"DB_USER",
"webtrees",
alternates=["MYSQL_USER", "MARIADB_USER", "POSTGRES_USER"],
),
dbpass=get_environment_variable(
"DB_PASS",
alternates=["MYSQL_PASSWORD", "MARIADB_PASSWORD", "POSTGRES_PASSWORD"],
),
dbname=get_environment_variable(
"DB_NAME",
default="webtrees",
alternates=["MYSQL_DATABASE", "MARIADB_DATABASE", "POSTGRES_DB"],
),
tblpfx=get_environment_variable("DB_PREFIX", "wt_"),
wtuser=get_environment_variable("WT_USER"),
wtname=get_environment_variable("WT_NAME"),
wtpass=get_environment_variable("WT_PASS"),
wtemail=get_environment_variable("WT_EMAIL"),
dbkey=get_environment_variable("DB_KEY"),
dbcert=get_environment_variable("DB_CERT"),
dbca=get_environment_variable("DB_CA"),
dbverify=truish(get_environment_variable("DB_VERIFY")),
phpmemorylimit=get_environment_variable("PHP_MEMORY_LIMIT", "1024M"),
phpmaxexecutiontime=get_environment_variable("PHP_MAX_EXECUTION_TIME", "90"),
phppostmaxsize=get_environment_variable("PHP_POST_MAX_SIZE", "50M"),
phpuploadmaxfilesize=get_environment_variable("PHP_UPLOAD_MAX_FILE_SIZE", "50M"),
puid=get_environment_variable("PUID", "33"), # www-data user
pgid=get_environment_variable("PGID", "33"),
)
ROOT = "/var/www/webtrees"
DATA_DIR = os.path.join(ROOT, "data")
CONFIG_FILE = os.path.join(DATA_DIR, "config.ini.php")
PHP_INI_FILE = "/usr/local/etc/php/php.ini"
os.chdir(ROOT)
def retry_urlopen(url: str, data: bytes) -> None:
"""
Retry a request until a postiive repsonse code is reached. Raises error if it fails.
"""
opener = request.build_opener(NoRedirect)
request.install_opener(opener)
for try_ in range(10):
try:
# make request
print2(f"Attempt {try_} for {url}")
resp = request.urlopen(url, data)
except urllib.error.HTTPError as e:
# capture error as well
resp = e
print2(f"Recieved HTTP {resp.status} response")
# check status code
# 302 is also accpetable in case the user selected something other than port 80
if resp.status in (200, 302):
return
# backoff
time.sleep(try_)
raise RuntimeError(f"Could not send a request to {url}")
def add_line_to_file(filename: str, newline: str) -> None:
"""
Add a new line to a file. If an existing line is found with the same
starting string, it will be replaced.
"""
newline += "\n"
# read file
with open(filename, "r") as fp:
lines = fp.readlines()
key = newline.split("=")[0]
# replace matching line
found = False
for i, line in enumerate(lines):
if line.startswith(key):
if line == newline:
return
lines[i] = newline
found = True
break
if not found:
lines.append(newline)
# write new contents
with open(filename, "w") as fp:
fp.writelines(lines)
def set_config_value(key: str, value: Optional[str]) -> None:
"""
In the config file, make sure the given key is set to the given value.
"""
if value is None:
return
print2(f"Setting value for {key} in config")
if not os.path.isfile(CONFIG_FILE):
print2(f"WARNING: {CONFIG_FILE} does not exist")
return
add_line_to_file(CONFIG_FILE, f'{key}="{value}"')
def set_php_ini_value(key: str, value: str) -> None:
"""
In the php.ini file, make sure the given key is set to the given value.
"""
print2(f"Setting value for {key} in php.ini")
add_line_to_file(PHP_INI_FILE, f"{key} = {value}")
def enable_apache_site(
enable_sites: List[Literal["webtrees", "webtrees-redir", "webtrees-ssl"]],
) -> None:
"""
Enable an Apache site.
"""
# update ssl apache config with cert path from env
ssl_site_file = "/etc/apache2/sites-available/webtrees-ssl.conf"
# make paths absolute
if not os.path.isabs(ENV.sslcertfile):
ENV.sslcertfile = os.path.join(DATA_DIR, ENV.sslcertfile)
if not os.path.isabs(ENV.sslcertkeyfile):
ENV.sslcertkeyfile = os.path.join(DATA_DIR, ENV.sslcertkeyfile)
# update file
with open(ssl_site_file, "r") as fp:
ssl_site_file_lines = fp.readlines()
new_ssl_site_file_lines = []
for line in ssl_site_file_lines:
if line.strip().startswith("SSLCertificateFile"):
line = line.replace(line.split()[1], ENV.sslcertfile)
elif line.strip().startswith("SSLCertificateKeyFile"):
line = line.replace(line.split()[1], ENV.sslcertkeyfile)
new_ssl_site_file_lines.append(line)
with open(ssl_site_file, "w") as fp:
fp.writelines(new_ssl_site_file_lines)
all_sites = ["webtrees", "webtrees-redir", "webtrees-ssl"]
# perl complains about locale to stderr, so disable that
# disable the other sites
for s in all_sites:
if s not in enable_sites:
print2(f"Disabling site {s}")
subprocess.check_call(
["a2dissite", s], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
# enable the desired sites
for s in enable_sites:
print2(f"Enabling site {s}")
subprocess.check_call(
["a2ensite", s], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
def perms() -> None:
"""
Set up folder permissions
"""
print2("Setting up folder permissions for uploads")
# https://github.com/linuxserver/docker-baseimage-alpine/blob/bef0f4cee208396c92c0fdd1426613de02698301/root/etc/s6-overlay/s6-rc.d/init-adduser/run#L4-L9
subprocess.check_call(["groupmod", "-o", "-g", ENV.pgid, "www-data"])
subprocess.check_call(["usermod", "-o", "-u", ENV.puid, "www-data"])
subprocess.check_call(["chown", "-R", "www-data:www-data", DATA_DIR])
if os.path.isfile(CONFIG_FILE):
subprocess.check_call(["chmod", "700", CONFIG_FILE])
def php_ini() -> None:
"""
Update PHP .ini file
"""
print2("Updating php.ini")
if not os.path.isfile(PHP_INI_FILE):
print2("Creating php.ini")
os.makedirs(os.path.dirname(PHP_INI_FILE), exist_ok=True)
with open(PHP_INI_FILE, "w") as fp:
fp.writelines(["[PHP]\n", "\n"])
set_php_ini_value("memory_limit", ENV.phpmemorylimit)
set_php_ini_value("max_execution_time", ENV.phpmaxexecutiontime)
set_php_ini_value("post_max_size", ENV.phppostmaxsize)
set_php_ini_value("upload_max_filesize", ENV.phpuploadmaxfilesize)
# https://webtrees.net/admin/performance/
set_php_ini_value("opcache.enable", "1")
set_php_ini_value(
"opcache.revalidate_freq", "60"
) # re check changed files every 60 seconds
set_php_ini_value("opcache.revalidate_path", "0")
def check_db_variables() -> bool:
"""
Check if all required database variables are present
"""
try:
assert ENV.dbtype is not None
assert ENV.dbname is not None
assert ENV.tblpfx is not None
if ENV.dbtype == DBType.sqlite:
ENV.dbhost = ""
ENV.dbport = ""
ENV.dbuser = ""
ENV.dbpass = ""
assert ENV.dbhost is not None
assert ENV.dbport is not None
assert ENV.dbuser is not None
assert ENV.dbpass is not None
except AssertionError:
print2("WARNING: Not all database variables are set")
return False
return True
def setup_wizard() -> None:
"""
Run the setup wizard
"""
if os.path.isfile(CONFIG_FILE):
return
print2("Attempting to automate setup wizard")
# make sure all the variables we need are not set to None
if not check_db_variables():
return
if any(
v is None
for v in [ENV.baseurl, ENV.wtname, ENV.wtuser, ENV.wtpass, ENV.wtemail]
):
print2("WARNING: Not all required variables were found for setup wizard")
return
assert ENV.baseurl is not None
if not ENV.baseurl.startswith("http"):
print2(
"WARNING: BASE_URL does not start with 'http'. This is likely not what you want."
)
print2("Automating setup wizard")
print2("Starting Apache in background")
# set us up to a known HTTP state
enable_apache_site(["webtrees"])
# run apache in the background
apache_proc = subprocess.Popen(["apache2-foreground"], stderr=subprocess.DEVNULL)
if ENV.dbtype in [DBType.mysql, DBType.pgsql]:
# for typing, check_db_variables already does this
assert ENV.dbhost is not None
# try to resolve the host
# most common error is wrong hostname
try:
socket.gethostbyname(ENV.dbhost)
except socket.gaierror:
print2(f"ERROR: Could not resolve database host '{ENV.dbhost}'")
print2(
"ERROR: You likely have the DBHOST environment variable set incorrectly."
)
print2("ERROR: Exiting.")
# stop apache
apache_proc.terminate()
# die
sys.exit(1)
# wait until database is ready
if ENV.dbtype == DBType.mysql:
# https://dev.mysql.com/doc/refman/8.0/en/mysqladmin.html#option_mysqladmin_user
# don't miss the capital P
cmd = ["mysqladmin", "ping", "-h", ENV.dbhost, "-P", ENV.dbport, "--silent"]
name = "MySQL"
elif ENV.dbtype == DBType.pgsql:
# https://www.postgresql.org/docs/current/app-pg-isready.html
cmd = ["pg_isready", "-h", ENV.dbhost, "-p", ENV.dbport, "--quiet"]
name = "PostgreSQL"
while subprocess.run(cmd).returncode != 0: # type: ignore
print2(f"Waiting for {name} server {ENV.dbhost}:{ENV.dbport} to be ready") # type: ignore
time.sleep(1)
else:
# sqlite
# let Apache start up
time.sleep(2)
# send it
url = "http://127.0.0.1:80/"
print2(f"Sending setup wizard request to {url}")
retry_urlopen(
url,
urlencode(
{
"lang": ENV.lang,
"tblpfx": ENV.tblpfx,
"baseurl": ENV.baseurl,
"dbtype": ENV.dbtype.value,
"dbhost": ENV.dbhost,
"dbport": ENV.dbport,
"dbuser": ENV.dbuser,
"dbpass": ENV.dbpass,
"dbname": ENV.dbname,
"wtname": ENV.wtname,
"wtuser": ENV.wtuser,
"wtpass": ENV.wtpass,
"wtemail": ENV.wtemail,
"step": "6",
}
).encode("ascii"),
)
print2("Stopping Apache")
apache_proc.terminate()
def update_config_file() -> None:
"""
Update the config file with items set via environment variables
"""
print2("Updating config file")
if not os.path.isfile(CONFIG_FILE):
print2(f"Config file not found at {CONFIG_FILE}. Nothing to update.")
return
# update independent values
set_config_value("rewrite_urls", str(int(ENV.prettyurls)))
set_config_value("base_url", ENV.baseurl)
# update database values as a group
if check_db_variables():
set_config_value("dbtype", ENV.dbtype.value)
set_config_value("dbhost", ENV.dbhost)
set_config_value("dbport", ENV.dbport)
set_config_value("dbuser", ENV.dbuser)
set_config_value("dbpass", ENV.dbpass)
set_config_value("dbname", ENV.dbname)
set_config_value("tblpfx", ENV.tblpfx)
# update databases verification values
if ENV.dbtype == DBType.mysql and all(
v is not None for v in [ENV.dbkey, ENV.dbcert, ENV.dbca]
):
set_config_value("dbkey", ENV.dbkey)
set_config_value("dbcert", ENV.dbcert)
set_config_value("dbca", ENV.dbca)
set_config_value("dbverify", str(int(ENV.dbverify)))
def https() -> None:
"""
Configure enabled Apache sites
"""
print2("Configuring HTTPS")
# no https
if not ENV.https:
print2("Removing HTTPS")
enable_apache_site(["webtrees"])
# https with redirect
elif ENV.httpsredirect:
print2("Adding HTTPS, with HTTPS redirect")
enable_apache_site(["webtrees-ssl", "webtrees-redir"])
# https no redirect
else:
print2("Adding HTTPS, removing HTTPS redirect")
enable_apache_site(["webtrees", "webtrees-ssl"])
def htaccess() -> None:
"""
Recreate .htaccess file if it ever deletes itself in the /data/ directory
"""
src_htaccess_file = os.path.join("/bak", "webtrees", "data", ".htaccess")
htaccess_file = os.path.join(DATA_DIR, ".htaccess")
if not os.path.isfile(src_htaccess_file):
# skip if the backup file does not exist
print2(
f"WARNING: {src_htaccess_file} does not exist. Skipping {htaccess_file} integrity check"
)
return
# https://github.com/NathanVaughn/webtrees-docker/issues/188
# In the event the htaccess file in the data directory is different
# from what it's supposed to be, replace it.
if os.path.isfile(htaccess_file) and filecmp.cmp(src_htaccess_file, htaccess_file):
print2(f"{htaccess_file} integrity check succeeded")
return
print2(f"WARNING: {htaccess_file} does not exist or does not match master copy")
shutil.copyfile(src_htaccess_file, htaccess_file)
print2(f"Updated {htaccess_file}")
def main() -> None:
# first, set up permissions
perms()
# create php config
php_ini()
# run the setup wizard if the config file doesn't exist
setup_wizard()
# update the config file
update_config_file()
# configure https
https()
# make sure .htaccess exists
htaccess()
# set up permissions again
perms()
print2("Starting Apache")
subprocess.run(["apache2-foreground"], stderr=subprocess.DEVNULL)
if __name__ == "__main__":
main()