From ff5cf0581db1460d7200dd13ecc46516deda5fc8 Mon Sep 17 00:00:00 2001 From: TechiesCamp <106984297+techiescamp@users.noreply.github.com> Date: Thu, 5 Oct 2023 06:59:20 +0530 Subject: [PATCH] Create serverpilot.py --- generic/serverpilot.py | 718 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 generic/serverpilot.py diff --git a/generic/serverpilot.py b/generic/serverpilot.py new file mode 100644 index 0000000..a0ad8f4 --- /dev/null +++ b/generic/serverpilot.py @@ -0,0 +1,718 @@ +#!/bin/sh +# -*- mode: Python -*- + +# The installer (this current file) is executed initially as a shell script. +# Here we either "exec python serverpilot-installer" or exit if we can't find a +# version of python this script is compatible with. +""":" +install_on_el5() { + echo "***************************************************************************" + echo "It looks like you're using RHEL/CentOS 5. ServerPilot only supports Ubuntu." + echo + echo "https://serverpilot.io/community/articles/server-requirements.html" + echo "***************************************************************************" + exit 1 +} + +# Some distros only have a "python3" command by default. +# Others may have Python 2.4 as "python" but with another version installed +# installed alongside it. +for py in python3 python27 python2.7 python26 python2.6 ; do + which $py >/dev/null 2>&1 && exec $py "$0" "$@" +done +if which python >/dev/null 2>&1 ; then + if [ "`python -V 2>&1`" == "Python 2.4.3" ]; then + install_on_el5 + exit 0 + fi + exec python "$0" "$@" +fi +echo "Unable to install ServerPilot. Python not found." +exit 1 +":""" + +""" +ServerPilot installer. See https://serverpilot.io +""" + +__copyright__ = 'Copyright (c) 2020, Less Bits, Inc.' +__license__ = 'Proprietary.' + +import sys + +# If Python is pre-2.7, this is an unsupported distro/version. Multiple parts +# of this installer rely on 2.7, so abort here with a useful error message. +if sys.version_info[:2] < (2, 7): + print('*' * 80) + print("Sorry, you can't connect this server to ServerPilot.") + print('') + print("You'll need to use Ubuntu 18.04, 20.04, or 22.04.") + print('') + print("More info here:") + print('') + print('https://serverpilot.io/community/articles/ubuntu-control-panel.html') + print('*' * 80) + sys.exit(1) + +import argparse +import base64 +try: + import ConfigParser as configparser +except: + import configparser +import datetime +import errno +import hashlib +import json +import os +import platform +import pwd +import shutil +import socket +import subprocess +import time +import tempfile +import traceback +try: + from urllib.parse import urlparse, urlencode + from urllib.request import urlopen, Request + from urllib.error import HTTPError +except ImportError: + from urlparse import urlparse + from urllib import urlencode + from urllib2 import urlopen, Request, HTTPError + + +# Python 3 renamed raw_input to input. +if sys.version_info[0] >= 3: + raw_input = input + + +API_URL = 'https://api.serverpilot.io/v1' + +SERVERPILOT_CONF_DIR = '/etc/serverpilot' + +AGENT_CONF_FILE = SERVERPILOT_CONF_DIR + '/agent.conf' + +SP_VERSION = 1 + +REPO_URL = 'https://download.serverpilot.io/ubuntu' + +INSTALLER_LOG_FILE = '/var/log/serverpilot/install.log' + +INSTALLER_VERSION = '1' + +DISTRO_UBUNTU = 'ubuntu' +DISTRO_UBUNTU_RELS = ['18.04', '20.04', '22.04'] +CHANNEL_STABLE = 'stable' + +DISTRO_UBUNTU_CODENAMES = { + '12.04': 'precise', + '14.04': 'trusty', + '16.04': 'xenial', + '18.04': 'bionic', + '20.04': 'focal', + '22.04': 'jammy', +} + +RELEASES_KEYRING_URL = 'https://download.serverpilot.io/serverpilot.gpg' +RELEASES_KEYRING_FILE = '/usr/share/keyrings/serverpilot.gpg' +RELEASES_KEYRING_FILE_SHA256 = 'b357636ac39a4f554a242f29b5997094b3d0f746c69a3768991dbfbe3b553589' + + +class ShellColor(): + # TODO: use the tput command to get the color sequences. + BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) + RESET_SEQ = '\033[0m' + FOREGROUND_COLOR_SEQ = '\033[1;3{}m' +# BOLD_SEQ = '\033[1m' + BOLD_SEQ = '\033[1' + + def __init__(self): + if self._is_bash(): + # Don't use color if stdout isn't a tty (e.g. it has been redirected + # to a log file). + self.use_color = subprocess.call("tty -s <&1", shell=True) == 0 + else: + self.use_color = False + + def _is_bash(self): + # Only detecting bash is probably overly-restrictive for now. + shell = os.environ.get('SHELL') + if not shell: + return False + if not shell: + return False + return os.path.basename(shell) == 'bash' + # tty -s <&1 + + def _color(self, msg, colorcode): + if self.use_color: + return self.FOREGROUND_COLOR_SEQ.format(colorcode) + msg + self.RESET_SEQ + else: + return msg + + def red(self, msg): + return self._color(msg, self.RED) + + def green(self, msg): + return self._color(msg, self.GREEN) + + def yellow(self, msg): + return self._color(msg, self.YELLOW) + + def bold(self, msg): + """Use bold instead of white because white foreground will be unreadable + if the user has a white background on their terminal.""" + return self._color(msg, self.BOLD_SEQ) + + +color = ShellColor() + + +class InstallError(Exception): + pass + + +class UserQuit(InstallError): + pass + + +class UnsupportedDistro(InstallError): + pass + + +class UnsupportedArch(InstallError): + pass + + +class UnsupportedKernel(InstallError): + pass + + +class NotEnoughMemory(InstallError): + pass + + +class SysuserAlreadyExists(InstallError): + pass + + +class AlreadyInstalled(InstallError): + pass + + +class CmdError(InstallError): + pass + + +def cmd(args, input=None): + if input is not None: + stdin = subprocess.PIPE + else: + stdin = None + try: + p = subprocess.Popen(args, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, stdin=stdin) + except OSError as exc: + raise_cmd_error(args, None, None, str(exc)) + stdout, stderr = p.communicate(input) + stdout = stdout.strip() + stderr = stderr.strip() + if p.returncode != 0: + raise_cmd_error(args, p.returncode, stdout, stderr) + return stdout + + +def check_exec_cmd(args): + retval = exec_cmd(args) + if retval != 0: + raise CmdError('The command "{}" exited with non-zero status {}.'.format( + ' '.join(args), retval)) + + +def exec_cmd(args): + '''Forks a child process which exec's the command and waits for the child + to exit. Returns the exit status of the child. + ''' + if os.fork() == 0: + os.execvp(args[0], args) + else: + try: + pid, exitinfo = os.wait() + except KeyboardInterrupt: + return -1 + if os.WIFEXITED(exitinfo): + return os.WEXITSTATUS(exitinfo) + else: + # It may have been killed. + return -1 + + +def format_cmd_output(retval, stdout, stderr): + lines = [] + lines.append('Return value: {0}'.format(retval)) + lines.append('stdout: {0}'.format(stdout)) + lines.append('stderr: {0}'.format(stderr)) + return os.linesep.join(lines) + + +def raise_cmd_error(args, retval, stdout, stderr): + msg = 'Error executing command: {0}\n'.format(args) + msg += format_cmd_output(retval, stdout, stderr) + raise CmdError(msg) + + +def read_file_lines(path): + f = open(path) + try: + return f.readlines() + finally: + f.close() + + +def write_file(path, contents, mode=0o600): + fd, temppath = tempfile.mkstemp() + try: + os.close(fd) + f = open(temppath, 'w') + try: + f.write(contents) + finally: + f.close() + os.chmod(temppath, mode) + shutil.move(temppath, path) + except: + os.remove(temppath) + raise + + +def mkdir_p(path): + try: + os.makedirs(path) + except OSError as exc: + if exc.errno == errno.EEXIST: + pass + else: raise + + +def write_to_log_file(str): + logdir = os.path.dirname(INSTALLER_LOG_FILE) + if not os.path.exists(logdir): + mkdir_p(logdir) + with open(INSTALLER_LOG_FILE, 'a') as f: + f.write(str + '\n') + + +def read_log_file(): + try: + with open(INSTALLER_LOG_FILE, 'r') as f: + return f.read() + except Exception as exc: + return None + + +def user_exists(user): + try: + pwd.getpwnam(user) + except KeyError: + return False + return True + + +class Installer(object): + + def __init__(self): + self.starttime = datetime.datetime.utcnow() + self.distroname = None + self.distroversion = None + self.arch = None + self.channel = CHANNEL_STABLE + self.serverid = None + self.apikey = None + self.skip_server_info = False + self.skip_mysql_check = False + self.skip_webserver_check = False + self.skip_install = False + + def report(self, status): + data = {'status': status} + data['hostname'] = socket.gethostname() + data['distroname'] = self.distroname + data['distroversion'] = self.distroversion + data['arch'] = self.arch + data['serverid'] = self.serverid + data['log'] = read_log_file() + data['channel'] = self.channel + data['elapsedtime'] = (datetime.datetime.utcnow() - self.starttime).total_seconds() + data['env'] = str(os.environ) + try: + urlopen(self.insecure_api_request('/installer/report', data=data)) + except Exception as exc: + self.record_error('Failed to send report', exc) + + def insecure_api_request(self, api_path, data=None): + """Make an API request that doesn't use SSL securely. + + Python 2.x does a terrible job of secure SSL. So, this method should + only be used for requests that don't need to be secure. + """ + headers = {} + if data is not None: + data = json.dumps(data).encode() + headers['Content-Type'] = 'application/json' + return Request(API_URL + api_path, data, headers) + + def insecure_hashedkey_api_request(self, api_path, data=None): + """Make an API request that doesn't use SSL securely. + + Python 2.x does a terrible job of secure SSL. So, this method should + only be used for requests that don't need to be secure. + """ + headers = {} + if data is not None: + data = json.dumps(data).encode() + headers['Content-Type'] = 'application/json' + hashedkey = hashlib.sha256(self.apikey.encode()).hexdigest() + authval = '{}:{}'.format(self.serverid, hashedkey) + b64authval = base64.b64encode(authval.encode()).decode() + headers['Authorization'] = 'Basic {}'.format(b64authval) + return Request(API_URL + api_path, data, headers) + + def record_error(self, msg, exc=None): + write_to_log_file(msg) + if exc: + write_to_log_file(traceback.format_exc()) + write_to_log_file('-' * 80) + + def run(self): + try: + self._run() + except SystemExit: + self.record_error('Exited due to SystemExit') + self.report('exit') + raise + except UserQuit: + self.record_error('Exited due to UserQuit') + self.report('abort') + raise + except InstallError as exc: + self.record_error('', exc) + self.report('fail') + raise + except Exception as exc: + self.record_error('', exc) + self.report('error') + raise + else: + self.report('success') + + def _run(self): + os.umask(0o077) + self.check_running_as_root() + write_to_log_file('=' * 80) + displaytime = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()) + write_to_log_file('Starting installation. {0}\n'.format(displaytime)) + try: + self.get_distro_and_version() + self.check_architecture() + self.check_kernel() + self.check_memory() + self.check_serverpilot_user() + self.check_mysql() + self.check_apache() + self.check_nginx() + # Do this last so conf file not written unless all checks pass. + self.ask_server_ident() + except KeyboardInterrupt: + write_to_log_file('Quiting installer.') + print('Quiting installer.') + raise UserQuit + self.install_packages() + self.show_finished_message() + + def get_distro_and_version(self): + try: + if os.path.exists('/etc/lsb-release'): + lines = read_file_lines('/etc/lsb-release') + # TODO: don't assume the lines are in a specific order. + if lines[0].startswith('DISTRIB_ID='): + self.distroname = lines[0].split('=')[1].strip().lower() + if lines[1].startswith('DISTRIB_RELEASE='): + self.distroversion = lines[1].split('=')[1].strip() + if self.distroname != DISTRO_UBUNTU: + raise UnsupportedDistro(self.distroname) + if self.distroversion not in DISTRO_UBUNTU_RELS: + raise UnsupportedDistro(self.distroversion) + else: + raise UnsupportedDistro + except UnsupportedDistro: + write_to_log_file('Unsupported operating system.') + print(color.red('*' * 80)) + print(color.bold("Sorry, you can't connect this server to ServerPilot.")) + print('') + print(color.bold("You'll need to use Ubuntu 18.04, 20.04, or 22.04.")) + print('') + print(color.bold("More info here:")) + print('') + print(color.bold('https://serverpilot.io/community/articles/ubuntu-control-panel.html')) + print(color.red('*' * 80)) + raise + + def check_running_as_root(self): + if os.getuid() != 0: + print(color.red('The ServerPilot installer must be run as root. Please run the exact installer command we provided.')) + sys.exit(1) + + def check_architecture(self): + self.arch = platform.machine() + if self.arch != 'x86_64': + write_to_log_file('Unsupported architecture: {}'.format(self.arch)) + print(color.red('ServerPilot only supports 64-bit servers.')) + print(color.red('Your server appears to be {0}.'.format(self.arch))) + raise UnsupportedArch + + def check_kernel(self): + kernel = platform.uname()[2] + kernelparts = kernel.split('.') + kernelmajor = int(kernelparts[0]) + kernelminor = int(kernelparts[1]) + if (kernelmajor, kernelminor) < (3, 2): + write_to_log_file('Unsupported kernel version: {}'.format(kernel)) + print(color.red('ServerPilot supports only Linux kernel versions >= 3.2.')) + print(color.red('Your system is running {0}.'.format(kernel))) + print(color.red('This can happen when running a server on OpenVZ with an old host kernel.')) + print(color.red('You should use a non-OpenVZ server (e.g. KVM or Xen) or switch providers.')) + raise UnsupportedKernel + + def check_memory(self): + with open('/proc/meminfo') as f: + for line in f.readlines(): + if 'MemTotal:' in line: + memtotal = int(line.split()[1]) / 1024 + break + else: + print(color.red('Could not determine available memory.')) + sys.exit(1) + # Allow a little less than 256 just in case a system is close. + if memtotal < 230: + write_to_log_file('Not enough system memory: {} MB'.format(memtotal)) + print(color.red('ServerPilot only supports systems with at least 256 MB of memory.')) + print(color.red('Your system has {0} MB.'.format(memtotal))) + raise NotEnoughMemory + + def check_serverpilot_user(self): + if user_exists('serverpilot'): + write_to_log_file('System user "serverpilot" already exists.') + print(color.red('System user "serverpilot" already exists.')) + raise SysuserAlreadyExists + + def ask_server_ident(self): + if self.skip_server_info: + return + if os.path.exists(AGENT_CONF_FILE): + write_to_log_file('File {} already exists.'.format(AGENT_CONF_FILE)) + print(color.red('You have already run the ServerPilot installer on this server.')) + raise AlreadyInstalled + if not self.serverid or not self.apikey: + write_to_log_file('Asking for server ID and API key.') + print(color.green("What's this server's ID and API key?")) + print(color.bold("You can get these by logging in to https://serverpilot.io and")) + print(color.bold("creating a new server. After you create the server, you will")) + print(color.bold("be shown the ID and API key to enter here.")) + while True: + if not self.serverid or not self.apikey: + sys.stdout.write(color.yellow('Server ID: ')) + self.serverid = raw_input() + sys.stdout.write(color.yellow('API key: ')) + self.apikey = raw_input() + try: + write_to_log_file('Testing apikey for serverid {}'.format(self.serverid)) + url = '/authtest/server/hashedkey' + req = self.insecure_hashedkey_api_request(url) + urlopen(req) + except HTTPError as e: + if e.code == 401: + write_to_log_file('Invalid Server ID and API key.') + print(color.red('Invalid Server ID and API key. Please enter them again.')) + self.serverid = None + self.apikey = None + continue + else: + raise + break + mkdir_p(SERVERPILOT_CONF_DIR) + with open(AGENT_CONF_FILE, 'w') as f: + config = configparser.ConfigParser() + config.add_section('server') + config.set('server', 'id', self.serverid) + config.set('server', 'apikey', self.apikey) + config.write(f) + print('') + + def check_mysql(self): + if self.skip_mysql_check: + return + + try: + stdout = cmd(['dpkg-query', '-W', '-f=${db:Status-Abbrev}', 'mysql-server']).decode().strip() + except CmdError: + pass + else: + if stdout != 'un': + write_to_log_file('MySQL server is already installed.') + print(color.red('MySQL server is already installed. You must install ServerPilot on a fresh server.')) + raise InstallError("mysql-server is already installed.") + + def check_apache(self): + if self.skip_webserver_check: + return + + try: + stdout = cmd(['dpkg-query', '-W', '-f=${db:Status-Abbrev}', 'apache2']).decode().strip() + except CmdError: + pass + else: + if stdout != 'un': + write_to_log_file('Apache is already installed.') + print(color.red('Apache is already installed. You must install ServerPilot on a fresh server.')) + raise InstallError("apache is already installed.") + + def check_nginx(self): + if self.skip_webserver_check: + return + + try: + stdout = cmd(['dpkg-query', '-W', '-f=${db:Status-Abbrev}', 'nginx']).decode().strip() + except CmdError: + pass + else: + if stdout != 'un': + write_to_log_file('Nginx is already installed.') + print(color.red('Nginx is already installed. You must install ServerPilot on a fresh server.')) + raise InstallError("nginx is already installed.") + + def add_repo(self): + def add_repo_deb(): + cmd(['wget', '-O', RELEASES_KEYRING_FILE, RELEASES_KEYRING_URL]) + os.chmod(RELEASES_KEYRING_FILE, 0o644) + sha256sum = cmd(['sha256sum', RELEASES_KEYRING_FILE]).decode().split()[0] + if sha256sum != RELEASES_KEYRING_FILE_SHA256: + raise InstallError("Keyring SHA256 checksum is {}, expected {}".format( + sha256sum, RELEASES_KEYRING_FILE_SHA256)) + + options = '[signed-by={keyring}]'.format(keyring=RELEASES_KEYRING_FILE) + path = '/etc/apt/sources.list.d/serverpilot.list' + contents = 'deb {options} {url} {codename} main'.format( + options=options, url=REPO_URL, + codename=DISTRO_UBUNTU_CODENAMES[self.distroversion]) + write_file(path, contents + os.linesep, 0o644) + + if self.distroname in [DISTRO_UBUNTU]: + add_repo_deb() + else: + raise InstallError('Unexpected distro: {0}'.format(self.distroname)) + + def install_packages(self): + if self.skip_install: + return + + def wait_for_apt(): + # Wait for up to 10 minutes for existing apt/dpkg commands to finish. + try: + for i in range(100): + stdout = cmd(['ps', '-e', '-o', 'comm']).decode() + if 'apt' not in stdout and 'dpkg' not in stdout: + break + print('Waiting for existing apt or dpkg process to exit.') + write_to_log_file('Waiting for existing apt or dpkg process to exit.') + time.sleep(6) + else: + raise InstallError('Giving up on waiting for existing apt or dpkg to exit.') + except CmdError as exc: + write_to_log_file('Unable to check for running apt or dpkg: {0}'.format(exc)) + + def install_packages_deb(): + os.putenv('DEBIAN_FRONTEND', 'noninteractive') + + try: + wait_for_apt() + check_exec_cmd(['apt-get', 'update']) + + # Try to fix any broken dependencies before proceeding. + wait_for_apt() + check_exec_cmd(['apt-get', '--assume-yes', '--fix-broken', 'install']) + + # Some systems don't have apt-transport-https installed by default. + wait_for_apt() + try: + check_exec_cmd(['apt-get', '--assume-yes', 'install', 'apt-transport-https']) + except CmdError: + # If we still weren't able to avoid a race with anything else installing + # packages, retry once here. + write_to_log_file('Unable to install apt-transport-https. Will try again.') + time.sleep(10) + wait_for_apt() + check_exec_cmd(['apt-get', '--assume-yes', 'install', 'apt-transport-https']) + + wait_for_apt() + check_exec_cmd(['apt-get', '--assume-yes', 'install', 'wget']) + + self.add_repo() + + # Now update again since we just added our repo. + wait_for_apt() + check_exec_cmd(['apt-get', 'update']) + + wait_for_apt() + pkgs = ['sp-serverpilot-agent'] + check_exec_cmd(['apt-get', '--assume-yes', 'install'] + pkgs) + except CmdError as exc: + print(color.red(str(exc))) + raise + + # We're actually serious that we only support Ubuntu LTS. If you are + # looking at these lines of code trying to hack around our installer + # telling you we don't support the OS you are trying to install on, + # please take us seriously and just use Ubuntu LTS. Thanks! + if (self.distroname == DISTRO_UBUNTU and + self.distroversion in DISTRO_UBUNTU_RELS): + install_packages_deb() + else: + raise NotImplementedError + + def show_finished_message(self): + write_to_log_file('Installation complete.') + print(color.green('*' * 80)) + print(color.bold('ServerPilot is now configuring and securing your server.')) + print(color.bold('Do not stop/resize/reboot your server right now.')) + print('') + print(color.bold('You can manage this server at https://manage.serverpilot.io')) + print(color.green('*' * 80)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--server-id') + parser.add_argument('--server-apikey') + parser.add_argument('--skip-server-info', action='store_true') + parser.add_argument('--skip-mysql-check', action='store_true') + parser.add_argument('--skip-webserver-check', action='store_true') + parser.add_argument('--skip-install', action='store_true') + args = parser.parse_args() + + installer = Installer() + installer.serverid = args.server_id + installer.apikey = args.server_apikey + installer.skip_server_info = args.skip_server_info + installer.skip_mysql_check = args.skip_mysql_check + installer.skip_webserver_check = args.skip_webserver_check + installer.skip_install = args.skip_install + try: + installer.run() + except Exception: + sys.exit(1) + + +if __name__ == '__main__': + main()