Files
DetectionLab/Vagrant/resources/malcolm/scripts/install.py
2021-08-06 10:35:01 +02:00

1334 lines
66 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2021 Battelle Energy Alliance, LLC. All rights reserved.
import argparse
import datetime
import fileinput
import getpass
import glob
import json
import os
import platform
import pprint
import math
import re
import shutil
import sys
import tarfile
import tempfile
import time
try:
from pwd import getpwuid
except ImportError:
getpwuid = None
from collections import defaultdict, namedtuple
from malcolm_common import *
###################################################################################################
DOCKER_COMPOSE_INSTALL_VERSION="1.27.4"
DEB_GPG_KEY_FINGERPRINT = '0EBFCD88' # used to verify GPG key for Docker Debian repository
MAC_BREW_DOCKER_PACKAGE = 'docker-edge'
MAC_BREW_DOCKER_SETTINGS = '/Users/{}/Library/Group Containers/group.com.docker/settings.json'
###################################################################################################
ScriptName = os.path.basename(__file__)
origPath = os.getcwd()
###################################################################################################
args = None
###################################################################################################
# get interactive user response to Y/N question
def InstallerYesOrNo(question, default=None, forceInteraction=False):
global args
return YesOrNo(question, default=default, forceInteraction=forceInteraction, acceptDefault=args.acceptDefaults)
###################################################################################################
# get interactive user response
def InstallerAskForString(question, default=None, forceInteraction=False):
global args
return AskForString(question, default=default, forceInteraction=forceInteraction, acceptDefault=args.acceptDefaults)
def TrueOrFalseQuote(expression):
return "'{}'".format('true' if expression else 'false')
###################################################################################################
class Installer(object):
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def __init__(self, debug=False, configOnly=False):
self.debug = debug
self.configOnly = configOnly
self.platform = platform.system()
self.scriptUser = getpass.getuser()
self.checkPackageCmds = []
self.installPackageCmds = []
self.requiredPackages = []
self.pipCmd = 'pip3'
if not Which(self.pipCmd, debug=self.debug): self.pipCmd = 'pip'
self.tempDirName = tempfile.mkdtemp()
self.totalMemoryGigs = 0.0
self.totalCores = 0
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def __del__(self):
shutil.rmtree(self.tempDirName, ignore_errors=True)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def run_process(self, command, stdout=True, stderr=True, stdin=None, privileged=False, retry=0, retrySleepSec=5):
# if privileged, put the sudo command at the beginning of the command
if privileged and (len(self.sudoCmd) > 0):
command = self.sudoCmd + command
return run_process(command, stdout=stdout, stderr=stderr, stdin=stdin, retry=retry, retrySleepSec=retrySleepSec, debug=self.debug)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def package_is_installed(self, package):
result = False
for cmd in self.checkPackageCmds:
ecode, out = self.run_process(cmd + [package])
if (ecode == 0):
result = True
break
return result
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def install_package(self, packages):
result = False
pkgs = []
for package in packages:
if not self.package_is_installed(package):
pkgs.append(package)
if (len(pkgs) > 0):
for cmd in self.installPackageCmds:
ecode, out = self.run_process(cmd + pkgs, privileged=True)
if (ecode == 0):
result = True
break
else:
result = True
return result
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def install_required_packages(self):
if (len(self.requiredPackages) > 0): eprint(f"Installing required packages: {self.requiredPackages}")
return self.install_package(self.requiredPackages)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def install_docker_images(self, docker_image_file):
result = False
if docker_image_file and os.path.isfile(docker_image_file) and InstallerYesOrNo(f'Load Malcolm Docker images from {docker_image_file}', default=True, forceInteraction=True):
ecode, out = self.run_process(['docker', 'load', '-q', '-i', docker_image_file], privileged=True)
if (ecode == 0):
result = True
else:
eprint(f"Loading Malcolm Docker images failed: {out}")
return result
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def install_malcolm_files(self, malcolm_install_file):
result = False
installPath = None
if malcolm_install_file and os.path.isfile(malcolm_install_file) and InstallerYesOrNo(f'Extract Malcolm runtime files from {malcolm_install_file}', default=True, forceInteraction=True):
# determine and create destination path for installation
while True:
defaultPath = os.path.join(origPath, 'malcolm')
installPath = InstallerAskForString(f'Enter installation path for Malcolm [{defaultPath}]', default=defaultPath, forceInteraction=True)
if (len(installPath) == 0): installPath = defaultPath
if os.path.isdir(installPath):
eprint(f"{installPath} already exists, please specify a different installation path")
else:
try:
os.makedirs(installPath)
except:
pass
if os.path.isdir(installPath):
break
else:
eprint(f"Failed to create {installPath}, please specify a different installation path")
# extract runtime files
if installPath and os.path.isdir(installPath):
if self.debug:
eprint(f"Created {installPath} for Malcolm runtime files")
tar = tarfile.open(malcolm_install_file)
try:
tar.extractall(path=installPath, numeric_owner=True)
finally:
tar.close()
# .tar.gz normally will contain an intermediate subdirectory. if so, move files back one level
childDir = glob.glob(f'{installPath}/*/')
if (len(childDir) == 1) and os.path.isdir(childDir[0]):
if self.debug:
eprint(f"{installPath} only contains {childDir[0]}")
for f in os.listdir(childDir[0]):
shutil.move(os.path.join(childDir[0], f), installPath)
shutil.rmtree(childDir[0], ignore_errors=True)
# verify the installation worked
if os.path.isfile(os.path.join(installPath, "docker-compose.yml")):
eprint(f"Malcolm runtime files extracted to {installPath}")
result = True
with open(os.path.join(installPath, "install_source.txt"), 'w') as f:
f.write(f'{os.path.basename(malcolm_install_file)} (installed {str(datetime.datetime.now())})\n')
else:
eprint(f"Malcolm install file extracted to {installPath}, but missing runtime files?")
return result, installPath
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def tweak_malcolm_runtime(self, malcolm_install_path, expose_elastic_default=False, expose_logstash_default=False, restart_mode_default=False):
global args
if not args.configFile:
# get a list of all of the docker-compose files
composeFiles = glob.glob(os.path.join(malcolm_install_path, 'docker-compose*.yml'))
elif os.path.isfile(args.configFile):
# single docker-compose file explicitly specified
composeFiles = [os.path.realpath(args.configFile)]
malcolm_install_path = os.path.dirname(composeFiles[0])
# figure out what UID/GID to run non-rood processes under docker as
puid = '1000'
pgid = '1000'
try:
if (self.platform == PLATFORM_LINUX):
puid = str(os.getuid())
pgid = str(os.getgid())
if (puid == '0') or (pgid == '0'):
raise Exception('it is preferrable not to run Malcolm as root, prompting for UID/GID instead')
except:
puid = '1000'
pgid = '1000'
while (not puid.isdigit()) or (not pgid.isdigit()) or (not InstallerYesOrNo(f'Malcolm processes will run as UID {puid} and GID {pgid}. Is this OK?', default=True)):
puid = InstallerAskForString('Enter user ID (UID) for running non-root Malcolm processes')
pgid = InstallerAskForString('Enter group ID (GID) for running non-root Malcolm processes')
# guestimate how much memory we should use based on total system memory
if self.debug:
eprint(f"{malcolm_install_path} contains {composeFiles}, system memory is {self.totalMemoryGigs} GiB")
if self.totalMemoryGigs >= 63.0:
esMemory = '30g'
lsMemory = '6g'
elif self.totalMemoryGigs >= 31.0:
esMemory = '21g'
lsMemory = '3500m'
elif self.totalMemoryGigs >= 15.0:
esMemory = '10g'
lsMemory = '3g'
elif self.totalMemoryGigs >= 11.0:
esMemory = '6g'
lsMemory = '2500m'
elif self.totalMemoryGigs >= 7.0:
eprint(f"Detected only {self.totalMemoryGigs} GiB of memory; performance will be suboptimal")
esMemory = '4g'
lsMemory = '2500m'
elif self.totalMemoryGigs > 0.0:
eprint(f"Detected only {self.totalMemoryGigs} GiB of memory; performance will be suboptimal")
esMemory = '3500m'
lsMemory = '2g'
else:
eprint("Failed to determine system memory size, using defaults; performance may be suboptimal")
esMemory = '8g'
lsMemory = '3g'
while not InstallerYesOrNo(f'Setting {esMemory} for Elasticsearch and {lsMemory} for Logstash. Is this OK?', default=True):
esMemory = InstallerAskForString('Enter memory for Elasticsearch (e.g., 16g, 9500m, etc.)')
lsMemory = InstallerAskForString('Enter memory for LogStash (e.g., 4g, 2500m, etc.)')
restartMode = None
allowedRestartModes = ('no', 'on-failure', 'always', 'unless-stopped')
if InstallerYesOrNo('Restart Malcolm upon system or Docker daemon restart?', default=restart_mode_default):
while restartMode not in allowedRestartModes:
restartMode = InstallerAskForString(f'Select Malcolm restart behavior {allowedRestartModes}', default='unless-stopped')
else:
restartMode = 'no'
if (restartMode == 'no'): restartMode = '"no"'
ldapStartTLS = False
ldapServerType = 'winldap'
useBasicAuth = not InstallerYesOrNo('Authenticate against Lightweight Directory Access Protocol (LDAP) server?', default=False)
if not useBasicAuth:
allowedLdapModes = ('winldap', 'openldap')
ldapServerType = None
while ldapServerType not in allowedLdapModes:
ldapServerType = InstallerAskForString(f'Select LDAP server compatibility type {allowedLdapModes}', default='winldap')
ldapStartTLS = InstallerYesOrNo('Use StartTLS for LDAP connection security?', default=True)
try:
with open(os.path.join(os.path.realpath(os.path.join(ScriptPath, "..")), ".ldap_config_defaults"), "w") as ldapDefaultsFile:
print(f"LDAP_SERVER_TYPE='{ldapServerType}'", file=ldapDefaultsFile)
print(f"LDAP_PROTO='{'ldap://' if useBasicAuth or ldapStartTLS else 'ldaps://'}'", file=ldapDefaultsFile)
print(f"LDAP_PORT='{3268 if ldapStartTLS else 3269}'", file=ldapDefaultsFile)
except:
pass
indexSnapshotDir = None
indexSnapshotCompressed = False
indexSnapshotAge = '0'
indexColdAge = '0'
indexCloseAge = '0'
indexDeleteAge = '0'
indexPruneSizeLimit = '0'
indexPruneNameSort = False
if InstallerYesOrNo('Configure Elasticsearch index state management?', default=False):
# configure snapshots
if InstallerYesOrNo('Configure index snapshots?', default=False):
# snapshot repository directory and compression
indexSnapshotDir = './elasticsearch-backup'
if not InstallerYesOrNo('Store snapshots locally in {}?'.format(os.path.join(malcolm_install_path, 'elasticsearch-backup')), default=True):
while True:
indexSnapshotDir = InstallerAskForString('Enter Elasticsearch index snapshot directory')
if (len(indexSnapshotDir) > 1) and os.path.isdir(indexSnapshotDir):
indexSnapshotDir = os.path.realpath(indexSnapshotDir)
break
indexSnapshotCompressed = InstallerYesOrNo('Compress index snapshots?', default=False)
# index age for snapshot
indexSnapshotAge = ''
while (not re.match(r'^\d+[dhms]$', indexSnapshotAge)) and (indexSnapshotAge != '0'):
indexSnapshotAge = InstallerAskForString('Enter index age for snapshot (e.g., 1d)')
# cold state age
if InstallerYesOrNo('Mark indices read-only as they age?', default=False):
indexColdAge = ''
while (not re.match(r'^\d+[dhms]$', indexColdAge)) and (indexColdAge != '0'):
indexColdAge = InstallerAskForString('Enter index age for "read-only" transition (e.g., 30d)')
# close state age
if InstallerYesOrNo('Close indices as they age?', default=False):
indexCloseAge = ''
while (not re.match(r'^\d+[dhms]$', indexCloseAge)) and (indexCloseAge != '0'):
indexCloseAge = InstallerAskForString('Enter index age for "close" transition (e.g., 60d)')
# delete state age
if InstallerYesOrNo('Delete indices as they age?', default=False):
indexDeleteAge = ''
while (not re.match(r'^\d+[dhms]$', indexDeleteAge)) and (indexDeleteAge != '0'):
indexDeleteAge = InstallerAskForString('Enter index age for "delete" transition (e.g., 365d)')
# delete based on index pattern size
if InstallerYesOrNo('Delete the oldest indices when the database exceeds a certain size?', default=False):
indexPruneSizeLimit = ''
while (not re.match(r'^\d+(\.\d+)?\s*[kmgtp%]?b?$', indexPruneSizeLimit, flags=re.IGNORECASE)) and (indexPruneSizeLimit != '0'):
indexPruneSizeLimit = InstallerAskForString('Enter index threshold (e.g., 250GB, 1TB, 60%, etc.)')
indexPruneNameSort = InstallerYesOrNo('Determine oldest indices by name (instead of creation time)?', default=True)
autoZeek = InstallerYesOrNo('Automatically analyze all PCAP files with Zeek?', default=True)
reverseDns = InstallerYesOrNo('Perform reverse DNS lookup locally for source and destination IP addresses in Zeek logs?', default=False)
autoOui = InstallerYesOrNo('Perform hardware vendor OUI lookups for MAC addresses?', default=True)
autoFreq = InstallerYesOrNo('Perform string randomness scoring on some fields?', default=True)
elasticOpen = InstallerYesOrNo('Expose Elasticsearch port to external hosts?', default=expose_elastic_default)
logstashOpen = InstallerYesOrNo('Expose Logstash port to external hosts?', default=expose_logstash_default)
logstashSsl = logstashOpen and InstallerYesOrNo('Should Logstash require SSL for Zeek logs? (Note: This requires the forwarder to be similarly configured and a corresponding copy of the client SSL files.)', default=True)
externalEsForward = InstallerYesOrNo('Forward Logstash logs to external Elasticstack instance?', default=False)
if externalEsForward:
externalEsHost = InstallerAskForString('Enter external Elasticstack host:port (e.g., 10.0.0.123:9200)')
externalEsSsl = InstallerYesOrNo(f'Connect to "{externalEsHost}" using SSL?', default=True)
externalEsSslVerify = externalEsSsl and InstallerYesOrNo(f'Require SSL certificate validation for communication with "{externalEsHost}"?', default=False)
else:
externalEsHost = ""
externalEsSsl = False
externalEsSslVerify = False
# input file extraction parameters
allowedFileCarveModes = ('none', 'known', 'mapped', 'all', 'interesting')
allowedFilePreserveModes = ('quarantined', 'all', 'none')
fileCarveModeUser = None
fileCarveMode = None
filePreserveMode = None
vtotApiKey = '0'
yaraScan = False
capaScan = False
clamAvScan = False
ruleUpdate = False
if InstallerYesOrNo('Enable file extraction with Zeek?', default=False):
while fileCarveMode not in allowedFileCarveModes:
fileCarveMode = InstallerAskForString(f'Select file extraction behavior {allowedFileCarveModes}', default=allowedFileCarveModes[0])
while filePreserveMode not in allowedFilePreserveModes:
filePreserveMode = InstallerAskForString(f'Select file preservation behavior {allowedFilePreserveModes}', default=allowedFilePreserveModes[0])
if fileCarveMode is not None:
if InstallerYesOrNo('Scan extracted files with ClamAV?', default=False):
clamAvScan = True
if InstallerYesOrNo('Scan extracted files with Yara?', default=False):
yaraScan = True
if InstallerYesOrNo('Scan extracted PE files with Capa?', default=False):
capaScan = True
if InstallerYesOrNo('Lookup extracted file hashes with VirusTotal?', default=False):
while (len(vtotApiKey) <= 1):
vtotApiKey = InstallerAskForString('Enter VirusTotal API key')
ruleUpdate = InstallerYesOrNo('Download updated scanner signatures periodically?', default=True)
if fileCarveMode not in allowedFileCarveModes:
fileCarveMode = allowedFileCarveModes[0]
if filePreserveMode not in allowedFileCarveModes:
filePreserveMode = allowedFilePreserveModes[0]
if (vtotApiKey is None) or (len(vtotApiKey) <= 1):
vtotApiKey = '0'
# input packet capture parameters
pcapNetSniff = False
pcapTcpDump = False
pcapIface = 'lo'
if InstallerYesOrNo('Should Malcolm capture network traffic to PCAP files?', default=False):
pcapIface = ''
while (len(pcapIface) <= 0):
pcapIface = InstallerAskForString('Specify capture interface(s) (comma-separated)')
pcapNetSniff = InstallerYesOrNo('Capture packets using netsniff-ng?', default=True)
pcapTcpDump = InstallerYesOrNo('Capture packets using tcpdump?', default=(not pcapNetSniff))
# modify specified values in-place in docker-compose files
for composeFile in composeFiles:
# save off owner of original files
composeFileStat = os.stat(composeFile)
origUid, origGuid = composeFileStat[4], composeFileStat[5]
composeFileHandle = fileinput.FileInput(composeFile, inplace=True, backup=None)
try:
servicesSectionFound = False
serviceIndent = None
currentService = None
for line in composeFileHandle:
line = line.rstrip("\n")
skipLine = False
# it would be cleaner to use something like PyYAML to do this, but I want to have as few dependencies
# as possible so we're going to do it janky instead
# determine indentation for each service section (assumes YML file is consistently indented)
if (not servicesSectionFound) and line.lower().startswith('services:'):
servicesSectionFound = True
elif servicesSectionFound and (serviceIndent is None):
indentMatch = re.search(r'^(\s+)\S+\s*:\s*$', line)
if indentMatch is not None:
serviceIndent = indentMatch.group(1)
# determine which service we're currently processing in the YML file
serviceStartLine = False
if servicesSectionFound and (serviceIndent is not None):
serviceMatch = re.search(fr'^{serviceIndent}(\S+)\s*:\s*$', line)
if serviceMatch is not None:
currentService = serviceMatch.group(1).lower()
serviceStartLine = True
if (currentService is not None) and (restartMode is not None) and re.match(r'^\s*restart\s*:.*$', line):
# elasticsearch backup directory
line = f"{serviceIndent * 2}restart: {restartMode}"
elif 'PUID' in line:
# process UID
line = re.sub(r'(PUID\s*:\s*)(\S+)', fr"\g<1>{puid}", line)
elif 'PGID' in line:
# process GID
line = re.sub(r'(PGID\s*:\s*)(\S+)', fr"\g<1>{pgid}", line)
elif 'NGINX_BASIC_AUTH' in line:
# basic (useBasicAuth=true) vs ldap (useBasicAuth=false)
line = re.sub(r'(NGINX_BASIC_AUTH\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(useBasicAuth)}", line)
elif 'NGINX_LDAP_TLS_STUNNEL' in line:
# StartTLS vs. ldap:// or ldaps://
line = re.sub(r'(NGINX_LDAP_TLS_STUNNEL\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(((not useBasicAuth) and ldapStartTLS))}", line)
elif 'ZEEK_EXTRACTOR_MODE' in line:
# zeek file extraction mode
line = re.sub(r'(ZEEK_EXTRACTOR_MODE\s*:\s*)(\S+)', fr"\g<1>'{fileCarveMode}'", line)
elif 'EXTRACTED_FILE_PRESERVATION' in line:
# zeek file preservation mode
line = re.sub(r'(EXTRACTED_FILE_PRESERVATION\s*:\s*)(\S+)', fr"\g<1>'{filePreserveMode}'", line)
elif 'VTOT_API2_KEY' in line:
# virustotal API key
line = re.sub(r'(VTOT_API2_KEY\s*:\s*)(\S+)', fr"\g<1>'{vtotApiKey}'", line)
elif 'EXTRACTED_FILE_ENABLE_YARA' in line:
# file scanning via yara
line = re.sub(r'(EXTRACTED_FILE_ENABLE_YARA\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(yaraScan)}", line)
elif 'EXTRACTED_FILE_ENABLE_CAPA' in line:
# PE file scanning via capa
line = re.sub(r'(EXTRACTED_FILE_ENABLE_CAPA\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(capaScan)}", line)
elif 'EXTRACTED_FILE_ENABLE_CLAMAV' in line:
# file scanning via clamav
line = re.sub(r'(EXTRACTED_FILE_ENABLE_CLAMAV\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(clamAvScan)}", line)
elif 'EXTRACTED_FILE_UPDATE_RULES' in line:
# rule updates (yara/capa via git, clamav via freshclam)
line = re.sub(r'(EXTRACTED_FILE_UPDATE_RULES\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(ruleUpdate)}", line)
elif 'PCAP_ENABLE_NETSNIFF' in line:
# capture pcaps via netsniff-ng
line = re.sub(r'(PCAP_ENABLE_NETSNIFF\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(pcapNetSniff)}", line)
elif 'PCAP_ENABLE_TCPDUMP' in line:
# capture pcaps via tcpdump
line = re.sub(r'(PCAP_ENABLE_TCPDUMP\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(pcapTcpDump)}", line)
elif 'PCAP_IFACE' in line:
# capture interface(s)
line = re.sub(r'(PCAP_IFACE\s*:\s*)(\S+)', fr"\g<1>'{pcapIface}'", line)
elif 'ES_JAVA_OPTS' in line:
# elasticsearch memory allowance
line = re.sub(r'(-Xm[sx])(\w+)', fr'\g<1>{esMemory}', line)
elif 'LS_JAVA_OPTS' in line:
# logstash memory allowance
line = re.sub(r'(-Xm[sx])(\w+)', fr'\g<1>{lsMemory}', line)
elif 'ZEEK_AUTO_ANALYZE_PCAP_FILES' in line:
# automatic pcap analysis with Zeek
line = re.sub(r'(ZEEK_AUTO_ANALYZE_PCAP_FILES\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(autoZeek)}", line)
elif 'LOGSTASH_REVERSE_DNS' in line:
# automatic local reverse dns lookup
line = re.sub(r'(LOGSTASH_REVERSE_DNS\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(reverseDns)}", line)
elif 'LOGSTASH_OUI_LOOKUP' in line:
# automatic MAC OUI lookup
line = re.sub(r'(LOGSTASH_OUI_LOOKUP\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(autoOui)}", line)
elif 'FREQ_LOOKUP' in line:
# freq.py string randomness calculations
line = re.sub(r'(FREQ_LOOKUP\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(autoFreq)}", line)
elif 'BEATS_SSL' in line:
# enable/disable beats SSL
line = re.sub(r'(BEATS_SSL\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(logstashOpen and logstashSsl)}", line)
elif (currentService == 'elasticsearch') and re.match(r'^\s*-.+:/opt/elasticsearch/backup(:.+)?\s*$', line) and (indexSnapshotDir is not None) and os.path.isdir(indexSnapshotDir):
# elasticsearch backup directory
volumeParts = line.strip().lstrip('-').lstrip().split(':')
volumeParts[0] = indexSnapshotDir
line = "{}- {}".format(serviceIndent * 3, ':'.join(volumeParts))
elif 'ISM_SNAPSHOT_AGE' in line:
# elasticsearch index state management snapshot age
line = re.sub(r'(ISM_SNAPSHOT_AGE\s*:\s*)(\S+)', fr"\g<1>'{indexSnapshotAge}'", line)
elif 'ISM_COLD_AGE' in line:
# elasticsearch index state management cold (read-only) age
line = re.sub(r'(ISM_COLD_AGE\s*:\s*)(\S+)', fr"\g<1>'{indexColdAge}'", line)
elif 'ISM_CLOSE_AGE' in line:
# elasticsearch index state management close age
line = re.sub(r'(ISM_CLOSE_AGE\s*:\s*)(\S+)', fr"\g<1>'{indexCloseAge}'", line)
elif 'ISM_DELETE_AGE' in line:
# elasticsearch index state management close age
line = re.sub(r'(ISM_DELETE_AGE\s*:\s*)(\S+)', fr"\g<1>'{indexDeleteAge}'", line)
elif 'ISM_SNAPSHOT_COMPRESSED' in line:
# elasticsearch index state management snapshot compression
line = re.sub(r'(ISM_SNAPSHOT_COMPRESSED\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(indexSnapshotCompressed)}", line)
elif 'ELASTICSEARCH_INDEX_SIZE_PRUNE_LIMIT' in line:
# delete based on index pattern size
line = re.sub(r'(ELASTICSEARCH_INDEX_SIZE_PRUNE_LIMIT\s*:\s*)(\S+)', fr"\g<1>'{indexPruneSizeLimit}'", line)
elif 'ELASTICSEARCH_INDEX_SIZE_PRUNE_NAME_SORT' in line:
# delete based on index pattern size (sorted by name vs. creation time)
line = re.sub(r'(ELASTICSEARCH_INDEX_SIZE_PRUNE_NAME_SORT\s*:\s*)(\S+)', fr"\g<1>{TrueOrFalseQuote(indexPruneNameSort)}", line)
elif 'ES_EXTERNAL_HOSTS' in line:
# enable/disable forwarding Logstash to external Elasticsearch instance
line = re.sub(r'(#\s*)?(ES_EXTERNAL_HOSTS\s*:\s*)(\S+)', fr"\g<2>'{externalEsHost}'", line)
elif 'ES_EXTERNAL_SSL_CERTIFICATE_VERIFICATION' in line:
# enable/disable SSL certificate verification for external Elasticsearch instance
line = re.sub(r'(#\s*)?(ES_EXTERNAL_SSL_CERTIFICATE_VERIFICATION\s*:\s*)(\S+)', fr"\g<2>{TrueOrFalseQuote(externalEsSsl and externalEsSslVerify)}", line)
elif 'ES_EXTERNAL_SSL' in line:
# enable/disable SSL certificate verification for external Elasticsearch instance
line = re.sub(r'(#\s*)?(ES_EXTERNAL_SSL\s*:\s*)(\S+)', fr"\g<2>{TrueOrFalseQuote(externalEsSsl)}", line)
elif logstashOpen and serviceStartLine and (currentService == 'logstash'):
# exposing logstash port 5044 to the world
print(line)
line = f"{serviceIndent * 2}ports:"
print(line)
line = f'{serviceIndent * 3}- "0.0.0.0:5044:5044"'
elif (not serviceStartLine) and (currentService == 'logstash') and re.match(fr'^({serviceIndent * 2}ports:|{serviceIndent * 3}-.*5044:5044)"?\s*$', line):
# remove previous/leftover/duplicate exposing logstash port 5044 to the world
skipLine = True
elif (not serviceStartLine) and (currentService == 'nginx-proxy') and re.match(r'^.*-.*\b9200:9200"?\s*$', line):
# comment/uncomment port forwarding for elastic based on elasticOpen
leadingSpaces = len(line) - len(line.lstrip())
if leadingSpaces <= 0: leadingSpaces = 6
line = f"{' ' * leadingSpaces}{'' if elasticOpen else '# '}{line.lstrip().lstrip('#').lstrip()}"
if not skipLine: print(line)
finally:
composeFileHandle.close()
# restore ownership
os.chown(composeFile, origUid, origGuid)
# if the Malcolm dir is owned by root, see if they want to reassign ownership to a non-root user
if (((self.platform == PLATFORM_LINUX) or (self.platform == PLATFORM_MAC)) and
(self.scriptUser == "root") and (getpwuid(os.stat(malcolm_install_path).st_uid).pw_name == self.scriptUser) and
InstallerYesOrNo(f'Set ownership of {malcolm_install_path} to an account other than {self.scriptUser}?', default=True, forceInteraction=True)):
tmpUser = ''
while (len(tmpUser) == 0):
tmpUser = InstallerAskForString('Enter user account').strip()
err, out = self.run_process(['id', '-g', '-n', tmpUser], stderr=True)
if (err == 0) and (len(out) > 0) and (len(out[0]) > 0):
tmpUser = f"{tmpUser}:{out[0]}"
err, out = self.run_process(['chown', '-R', tmpUser, malcolm_install_path], stderr=True)
if (err == 0):
if self.debug: eprint(f"Changing ownership of {malcolm_install_path} to {tmpUser} succeeded")
else:
eprint(f"Changing ownership of {malcolm_install_path} to {tmpUser} failed: {out}")
###################################################################################################
class LinuxInstaller(Installer):
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def __init__(self, debug=False, configOnly=False):
super().__init__(debug, configOnly)
self.distro = None
self.codename = None
self.release = None
# determine the distro (e.g., ubuntu) and code name (e.g., bionic) if applicable
# check /etc/os-release values first
if os.path.isfile('/etc/os-release'):
osInfo = dict()
with open("/etc/os-release", 'r') as f:
for line in f:
try:
k, v = line.rstrip().split("=")
osInfo[k] = v.strip('"')
except:
pass
if ('NAME' in osInfo) and (len(osInfo['NAME']) > 0):
distro = osInfo['NAME'].lower().split()[0]
if ('VERSION_CODENAME' in osInfo) and (len(osInfo['VERSION_CODENAME']) > 0):
codename = osInfo['VERSION_CODENAME'].lower().split()[0]
if ('VERSION_ID' in osInfo) and (len(osInfo['VERSION_ID']) > 0):
release = osInfo['VERSION_ID'].lower().split()[0]
# try lsb_release next
if (self.distro is None):
err, out = self.run_process(['lsb_release', '-is'], stderr=False)
if (err == 0) and (len(out) > 0):
self.distro = out[0].lower()
if (self.codename is None):
err, out = self.run_process(['lsb_release', '-cs'], stderr=False)
if (err == 0) and (len(out) > 0):
self.codename = out[0].lower()
if (self.release is None):
err, out = self.run_process(['lsb_release', '-rs'], stderr=False)
if (err == 0) and (len(out) > 0):
self.release = out[0].lower()
# try release-specific files
if (self.distro is None):
if os.path.isfile('/etc/centos-release'):
distroFile = '/etc/centos-release'
if os.path.isfile('/etc/redhat-release'):
distroFile = '/etc/redhat-release'
elif os.path.isfile('/etc/issue'):
distroFile = '/etc/issue'
else:
distroFile = None
if (distroFile is not None):
with open(distroFile, 'r') as f:
distroVals = f.read().lower().split()
distroNums = [x for x in distroVals if x[0].isdigit()]
self.distro = distroVals[0]
if (self.release is None) and (len(distroNums) > 0):
self.release = distroNums[0]
if (self.distro is None):
self.distro = "linux"
if self.debug:
eprint(f"distro: {self.distro}{f' {self.codename}' if self.codename else ''}{f' {self.release}' if self.release else ''}")
if not self.codename: self.codename = self.distro
# determine packages required by Malcolm itself (not docker, those will be done later)
if (self.distro == PLATFORM_LINUX_UBUNTU) or (self.distro == PLATFORM_LINUX_DEBIAN):
self.requiredPackages.extend(['apache2-utils', 'make', 'openssl'])
elif (self.distro == PLATFORM_LINUX_FEDORA) or (self.distro == PLATFORM_LINUX_CENTOS):
self.requiredPackages.extend(['httpd-tools', 'make', 'openssl'])
# on Linux this script requires root, or sudo, unless we're in local configuration-only mode
if os.getuid() == 0:
self.scriptUser = "root"
self.sudoCmd = []
else:
self.sudoCmd = ["sudo", "-n"]
err, out = self.run_process(['whoami'], privileged=True)
if ((err != 0) or (len(out) == 0) or (out[0] != 'root')) and (not self.configOnly):
raise Exception(f'{ScriptName} must be run as root, or {self.sudoCmd} must be available')
# determine command to use to query if a package is installed
if Which('dpkg', debug=self.debug):
os.environ["DEBIAN_FRONTEND"] = "noninteractive"
self.checkPackageCmds.append(['dpkg', '-s'])
elif Which('rpm', debug=self.debug):
self.checkPackageCmds.append(['rpm', '-q'])
elif Which('dnf', debug=self.debug):
self.checkPackageCmds.append(['dnf', 'list', 'installed'])
elif Which('yum', debug=self.debug):
self.checkPackageCmds.append(['yum', 'list', 'installed'])
# determine command to install a package from the distro's repos
if Which('apt-get', debug=self.debug):
self.installPackageCmds.append(['apt-get', 'install', '-y', '-qq'])
elif Which('apt', debug=self.debug):
self.installPackageCmds.append(['apt', 'install', '-y', '-qq'])
elif Which('dnf', debug=self.debug):
self.installPackageCmds.append(['dnf', '-y', 'install', '--nobest'])
elif Which('yum', debug=self.debug):
self.installPackageCmds.append(['yum', '-y', 'install'])
# determine total system memory
try:
totalMemBytes = os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES')
self.totalMemoryGigs = math.ceil(totalMemBytes/(1024.**3))
except:
self.totalMemoryGigs = 0.0
# determine total system memory a different way if the first way didn't work
if (self.totalMemoryGigs <= 0.0):
err, out = self.run_process(['awk', '/MemTotal/ { printf "%.0f \\n", $2 }', '/proc/meminfo'])
if (err == 0) and (len(out) > 0):
totalMemKiloBytes = int(out[0])
self.totalMemoryGigs = math.ceil(totalMemKiloBytes/(1024.**2))
# determine total system CPU cores
try:
self.totalCores = os.sysconf('SC_NPROCESSORS_ONLN')
except:
self.totalCores = 0
# determine total system CPU cores a different way if the first way didn't work
if (self.totalCores <= 0):
err, out = self.run_process(['grep', '-c', '^processor', '/proc/cpuinfo'])
if (err == 0) and (len(out) > 0):
self.totalCores = int(out[0])
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def install_docker(self):
result = False
# first see if docker is already installed and runnable
err, out = self.run_process(['docker', 'info'], privileged=True)
if (err == 0):
result = True
elif InstallerYesOrNo('"docker info" failed, attempt to install Docker?', default=True):
if InstallerYesOrNo('Attempt to install Docker using official repositories?', default=True):
# install required packages for repo-based install
if self.distro == PLATFORM_LINUX_UBUNTU:
requiredRepoPackages = ['apt-transport-https', 'ca-certificates', 'curl', 'gnupg-agent', 'software-properties-common']
elif self.distro == PLATFORM_LINUX_DEBIAN:
requiredRepoPackages = ['apt-transport-https', 'ca-certificates', 'curl', 'gnupg2', 'software-properties-common']
elif self.distro == PLATFORM_LINUX_FEDORA:
requiredRepoPackages = ['dnf-plugins-core']
elif self.distro == PLATFORM_LINUX_CENTOS:
requiredRepoPackages = ['yum-utils', 'device-mapper-persistent-data', 'lvm2']
else:
requiredRepoPackages = []
if len(requiredRepoPackages) > 0:
eprint(f"Installing required packages: {requiredRepoPackages}")
self.install_package(requiredRepoPackages)
# install docker via repo if possible
dockerPackages = []
if ((self.distro == PLATFORM_LINUX_UBUNTU) or (self.distro == PLATFORM_LINUX_DEBIAN)) and self.codename:
# for debian/ubuntu, add docker GPG key and check its fingerprint
if self.debug:
eprint("Requesting docker GPG key for package signing")
dockerGpgKey = requests.get(f'https://download.docker.com/linux/{self.distro}/gpg', allow_redirects=True)
err, out = self.run_process(['apt-key', 'add'], stdin=dockerGpgKey.content.decode(sys.getdefaultencoding()), privileged=True, stderr=False)
if (err == 0):
err, out = self.run_process(['apt-key', 'fingerprint', DEB_GPG_KEY_FINGERPRINT], privileged=True, stderr=False)
# add docker .deb repository
if (err == 0):
if self.debug:
eprint("Adding docker repository")
err, out = self.run_process(['add-apt-repository', '-y', '-r', f'deb [arch=amd64] https://download.docker.com/linux/{self.distro} {self.codename} stable'], privileged=True)
err, out = self.run_process(['add-apt-repository', '-y', '-u', f'deb [arch=amd64] https://download.docker.com/linux/{self.distro} {self.codename} stable'], privileged=True)
# docker packages to install
if (err == 0):
dockerPackages.extend(['docker-ce', 'docker-ce-cli', 'containerd.io'])
elif self.distro == PLATFORM_LINUX_FEDORA:
# add docker fedora repository
if self.debug:
eprint("Adding docker repository")
err, out = self.run_process(['dnf', 'config-manager', '-y', '--add-repo', 'https://download.docker.com/linux/fedora/docker-ce.repo'], privileged=True)
# docker packages to install
if (err == 0):
dockerPackages.extend(['docker-ce', 'docker-ce-cli', 'containerd.io'])
elif self.distro == PLATFORM_LINUX_CENTOS:
# add docker centos repository
if self.debug:
eprint("Adding docker repository")
err, out = self.run_process(['yum-config-manager', '-y', '--add-repo', 'https://download.docker.com/linux/centos/docker-ce.repo'], privileged=True)
# docker packages to install
if (err == 0):
dockerPackages.extend(['docker-ce', 'docker-ce-cli', 'containerd.io'])
else:
err, out = None, None
if len(dockerPackages) > 0:
eprint(f"Installing docker packages: {dockerPackages}")
if self.install_package(dockerPackages):
eprint("Installation of docker packages apparently succeeded")
result = True
else:
eprint("Installation of docker packages failed")
# the user either chose not to use the official repos, the official repo installation failed, or there are not official repos available
# see if we want to attempt using the convenience script at https://get.docker.com (see https://github.com/docker/docker-install)
if not result and InstallerYesOrNo('Docker not installed via official repositories. Attempt to install Docker via convenience script (please read https://github.com/docker/docker-install)?', default=False):
tempFileName = os.path.join(self.tempDirName, 'docker-install.sh')
if DownloadToFile("https://get.docker.com/", tempFileName, debug=self.debug):
os.chmod(tempFileName, 493) # 493 = 0o755
err, out = self.run_process(([tempFileName]), privileged=True)
if (err == 0):
eprint("Installation of docker apparently succeeded")
result = True
else:
eprint(f"Installation of docker failed: {out}")
else:
eprint(f"Downloading {dockerComposeUrl} to {tempFileName} failed")
if result and ((self.distro == PLATFORM_LINUX_FEDORA) or (self.distro == PLATFORM_LINUX_CENTOS)):
# centos/fedora don't automatically start/enable the daemon, so do so now
err, out = self.run_process(['systemctl', 'start', 'docker'], privileged=True)
if (err == 0):
err, out = self.run_process(['systemctl', 'enable', 'docker'], privileged=True)
if (err != 0):
eprint(f"Enabling docker service failed: {out}")
else:
eprint(f"Starting docker service failed: {out}")
# at this point we either have installed docker successfully or we have to give up, as we've tried all we could
err, out = self.run_process(['docker', 'info'], privileged=True, retry=6, retrySleepSec=5)
if result and (err == 0):
if self.debug:
eprint('"docker info" succeeded')
# add non-root user to docker group if required
usersToAdd = []
if self.scriptUser == 'root':
while InstallerYesOrNo(f"Add {'a' if len(usersToAdd) == 0 else 'another'} non-root user to the \"docker\" group?"):
tmpUser = InstallerAskForString('Enter user account')
if (len(tmpUser) > 0): usersToAdd.append(tmpUser)
else:
usersToAdd.append(self.scriptUser)
for user in usersToAdd:
err, out = self.run_process(['usermod', '-a', '-G', 'docker', user], privileged=True)
if (err == 0):
if self.debug:
eprint(f'Adding {user} to "docker" group succeeded')
else:
eprint(f'Adding {user} to "docker" group failed')
elif (err != 0):
result = False
raise Exception(f'{ScriptName} requires docker, please see {DOCKER_INSTALL_URLS[self.distro]}')
return result
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def install_docker_compose(self):
result = False
dockerComposeCmd = 'docker-compose'
if not Which(dockerComposeCmd, debug=self.debug) and os.path.isfile('/usr/local/bin/docker-compose'):
dockerComposeCmd = '/usr/local/bin/docker-compose'
# first see if docker-compose is already installed and runnable (try non-root and root)
err, out = self.run_process([dockerComposeCmd, 'version'], privileged=False)
if (err != 0):
err, out = self.run_process([dockerComposeCmd, 'version'], privileged=True)
if (err != 0) and InstallerYesOrNo('"docker-compose version" failed, attempt to install docker-compose?', default=True):
if InstallerYesOrNo('Install docker-compose directly from docker github?', default=True):
# download docker-compose from github and put it in /usr/local/bin
# need to know some linux platform info
unames = []
err, out = self.run_process((['uname', '-s']))
if (err == 0) and (len(out) > 0): unames.append(out[0])
err, out = self.run_process((['uname', '-m']))
if (err == 0) and (len(out) > 0): unames.append(out[0])
if len(unames) == 2:
# download docker-compose from github and save it to a temporary file
tempFileName = os.path.join(self.tempDirName, dockerComposeCmd)
dockerComposeUrl = f"https://github.com/docker/compose/releases/download/{DOCKER_COMPOSE_INSTALL_VERSION}/docker-compose-{unames[0]}-{unames[1]}"
if DownloadToFile(dockerComposeUrl, tempFileName, debug=self.debug):
os.chmod(tempFileName, 493) # 493 = 0o755, mark as executable
# put docker-compose into /usr/local/bin
err, out = self.run_process((['cp', '-f', tempFileName, '/usr/local/bin/docker-compose']), privileged=True)
if (err == 0):
eprint("Download and installation of docker-compose apparently succeeded")
dockerComposeCmd = '/usr/local/bin/docker-compose'
else:
raise Exception(f'Error copying {tempFileName} to /usr/local/bin: {out}')
else:
eprint(f"Downloading {dockerComposeUrl} to {tempFileName} failed")
elif InstallerYesOrNo('Install docker-compose via pip (privileged)?', default=False):
# install docker-compose via pip (as root)
err, out = self.run_process([self.pipCmd, 'install', dockerComposeCmd], privileged=True)
if (err == 0):
eprint("Installation of docker-compose apparently succeeded")
else:
eprint(f"Install docker-compose via pip failed with {err}, {out}")
elif InstallerYesOrNo('Install docker-compose via pip (user)?', default=True):
# install docker-compose via pip (regular user)
err, out = self.run_process([self.pipCmd, 'install', dockerComposeCmd], privileged=False)
if (err == 0):
eprint("Installation of docker-compose apparently succeeded")
else:
eprint(f"Install docker-compose via pip failed with {err}, {out}")
# see if docker-compose is now installed and runnable (try non-root and root)
err, out = self.run_process([dockerComposeCmd, 'version'], privileged=False)
if (err != 0):
err, out = self.run_process([dockerComposeCmd, 'version'], privileged=True)
if (err == 0):
result = True
if self.debug:
eprint('"docker-compose version" succeeded')
else:
raise Exception(f'{ScriptName} requires docker-compose, please see {DOCKER_COMPOSE_INSTALL_URLS[self.platform]}')
return result
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def tweak_system_files(self):
# make some system configuration changes with permission
ConfigLines = namedtuple("ConfigLines", ["distros", "filename", "prefix", "description", "lines"], rename=False)
configLinesToAdd = [ConfigLines([],
'/etc/sysctl.conf',
'fs.file-max=',
'fs.file-max increases allowed maximum for file handles',
['# the maximum number of open file handles',
'fs.file-max=2097152']),
ConfigLines([],
'/etc/sysctl.conf',
'fs.inotify.max_user_watches=',
'fs.inotify.max_user_watches increases allowed maximum for monitored files',
['# the maximum number of user inotify watches',
'fs.inotify.max_user_watches=131072']),
ConfigLines([],
'/etc/sysctl.conf',
'fs.inotify.max_queued_events=',
'fs.inotify.max_queued_events increases queue size for monitored files',
['# the inotify event queue size',
'fs.inotify.max_queued_events=131072']),
ConfigLines([],
'/etc/sysctl.conf',
'fs.inotify.max_user_instances=',
'fs.inotify.max_user_instances increases allowed maximum monitor file watchers',
['# the maximum number of user inotify monitors',
'fs.inotify.max_user_instances=512']),
ConfigLines([],
'/etc/sysctl.conf',
'vm.max_map_count=',
'vm.max_map_count increases allowed maximum for memory segments',
['# the maximum number of memory map areas a process may have',
'vm.max_map_count=262144']),
ConfigLines([],
'/etc/sysctl.conf',
'net.core.somaxconn=',
'net.core.somaxconn increases allowed maximum for socket connections',
['# the maximum number of incoming connections',
'net.core.somaxconn=65535']),
ConfigLines([],
'/etc/sysctl.conf',
'vm.swappiness=',
'vm.swappiness adjusts the preference of the system to swap vs. drop runtime memory pages',
['# decrease "swappiness" (swapping out runtime memory vs. dropping pages)',
'vm.swappiness=1']),
ConfigLines([],
'/etc/sysctl.conf',
'vm.dirty_background_ratio=',
'vm.dirty_background_ratio defines the percentage of system memory fillable with "dirty" pages before flushing',
['# the % of system memory fillable with "dirty" pages before flushing',
'vm.dirty_background_ratio=40']),
ConfigLines([],
'/etc/sysctl.conf',
'vm.dirty_background_ratio=',
'vm.dirty_background_ratio defines the percentage of dirty system memory before flushing',
['# maximum % of dirty system memory before committing everything',
'vm.dirty_background_ratio=40']),
ConfigLines([],
'/etc/sysctl.conf',
'vm.dirty_ratio=',
'vm.dirty_ratio defines the maximum percentage of dirty system memory before committing everything',
['# maximum % of dirty system memory before committing everything',
'vm.dirty_ratio=80']),
ConfigLines(['centos', 'core'],
'/etc/systemd/system.conf.d/limits.conf',
'',
'/etc/systemd/system.conf.d/limits.conf increases the allowed maximums for file handles and memlocked segments',
['[Manager]',
'DefaultLimitNOFILE=65535:65535',
'DefaultLimitMEMLOCK=infinity']),
ConfigLines(['bionic', 'cosmic', 'disco', 'eoan', 'focal', 'groovy', 'stretch', 'buster', 'bullseye', 'sid', 'fedora'],
'/etc/security/limits.d/limits.conf',
'',
'/etc/security/limits.d/limits.conf increases the allowed maximums for file handles and memlocked segments',
['* soft nofile 65535',
'* hard nofile 65535',
'* soft memlock unlimited',
'* hard memlock unlimited'])]
for config in configLinesToAdd:
if (((len(config.distros) == 0) or (self.codename in config.distros)) and
(os.path.isfile(config.filename) or InstallerYesOrNo(f'\n{config.description}\n{config.filename} does not exist, create it?', default=True))):
confFileLines = [line.rstrip('\n') for line in open(config.filename)] if os.path.isfile(config.filename) else []
if ((len(confFileLines) == 0) or
(not os.path.isfile(config.filename) and (len(config.prefix) == 0)) or
((len(list(filter(lambda x: x.startswith(config.prefix), confFileLines))) == 0) and
InstallerYesOrNo(f'\n{config.description}\n{config.prefix} appears to be missing from {config.filename}, append it?', default=True))):
echoNewLineJoin = '\\n'
err, out = self.run_process(['bash',
'-c',
f"mkdir -p {os.path.dirname(config.filename)} && echo -n -e '{echoNewLineJoin}{echoNewLineJoin.join(config.lines)}{echoNewLineJoin} >> '{config.filename}'"], privileged=True)
###################################################################################################
class MacInstaller(Installer):
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def __init__(self, debug=False, configOnly=False):
super().__init__(debug, configOnly)
self.sudoCmd = []
# first see if brew is already installed and runnable
err, out = self.run_process(['brew', 'info'])
brewInstalled = (err == 0)
if brewInstalled and InstallerYesOrNo('Homebrew is installed: continue with Homebrew?', default=True):
self.useBrew = True
else:
self.useBrew = False
eprint('Docker can be installed and maintained with Homebrew, or manually.')
if (not brewInstalled) and (not InstallerYesOrNo('Homebrew is not installed: continue with manual installation?', default=False)):
raise Exception(f'Follow the steps at {HOMEBREW_INSTALL_URLS[self.platform]} to install Homebrew, then re-run {ScriptName}')
if self.useBrew:
# make sure we have brew cask
err, out = self.run_process(['brew', 'info', 'cask'])
if (err != 0):
self.install_package(['cask'])
if (err == 0):
if self.debug: eprint('"brew install cask" succeeded')
else:
eprint(f'"brew install cask" failed with {err}, {out}')
err, out = self.run_process(['brew', 'tap', 'homebrew/cask-versions'])
if (err == 0):
if self.debug: eprint('"brew tap homebrew/cask-versions" succeeded')
else:
eprint(f'"brew tap homebrew/cask-versions" failed with {err}, {out}')
self.checkPackageCmds.append(['brew', 'cask', 'ls', '--versions'])
self.installPackageCmds.append(['brew', 'cask', 'install'])
# determine total system memory
try:
totalMemBytes = os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES')
self.totalMemoryGigs = math.ceil(totalMemBytes/(1024.**3))
except:
self.totalMemoryGigs = 0.0
# determine total system memory a different way if the first way didn't work
if (self.totalMemoryGigs <= 0.0):
err, out = self.run_process(['sysctl', '-n', 'hw.memsize'])
if (err == 0) and (len(out) > 0):
totalMemBytes = int(out[0])
self.totalMemoryGigs = math.ceil(totalMemBytes/(1024.**3))
# determine total system CPU cores
try:
self.totalCores = os.sysconf('SC_NPROCESSORS_ONLN')
except:
self.totalCores = 0
# determine total system CPU cores a different way if the first way didn't work
if (self.totalCores <= 0):
err, out = self.run_process(['sysctl', '-n', 'hw.ncpu'])
if (err == 0) and (len(out) > 0):
self.totalCores = int(out[0])
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def install_docker(self):
result = False
# first see if docker is already installed/runnable
err, out = self.run_process(['docker', 'info'])
if (err != 0) and self.useBrew and self.package_is_installed(MAC_BREW_DOCKER_PACKAGE):
# if docker is installed via brew, but not running, prompt them to start it
eprint(f'{MAC_BREW_DOCKER_PACKAGE} appears to be installed via Homebrew, but "docker info" failed')
while True:
response = InstallerAskForString('Starting Docker the first time may require user interaction. Please find and start Docker in the Applications folder, then return here and type YES').lower()
if (response == 'yes'):
break
err, out = self.run_process(['docker', 'info'], retry=12, retrySleepSec=5)
# did docker info work?
if (err == 0):
result = True
elif InstallerYesOrNo('"docker info" failed, attempt to install Docker?', default=True):
if self.useBrew:
# install docker via brew cask (requires user interaction)
dockerPackages = [MAC_BREW_DOCKER_PACKAGE]
eprint(f"Installing docker packages: {dockerPackages}")
if self.install_package(dockerPackages):
eprint("Installation of docker packages apparently succeeded")
while True:
response = InstallerAskForString('Starting Docker the first time may require user interaction. Please find and start Docker in the Applications folder, then return here and type YES').lower()
if (response == 'yes'):
break
else:
eprint("Installation of docker packages failed")
else:
# install docker via downloaded dmg file (requires user interaction)
dlDirName = f'/Users/{self.scriptUser}/Downloads'
if os.path.isdir(dlDirName):
tempFileName = os.path.join(dlDirName, 'Docker.dmg')
else:
tempFileName = os.path.join(self.tempDirName, 'Docker.dmg')
if DownloadToFile('https://download.docker.com/mac/edge/Docker.dmg', tempFileName, debug=self.debug):
while True:
response = InstallerAskForString(f'Installing and starting Docker the first time may require user interaction. Please open Finder and install {tempFileName}, start Docker from the Applications folder, then return here and type YES').lower()
if (response == 'yes'):
break
# at this point we either have installed docker successfully or we have to give up, as we've tried all we could
err, out = self.run_process(['docker', 'info'], retry=12, retrySleepSec=5)
if (err == 0):
result = True
if self.debug:
eprint('"docker info" succeeded')
elif (err != 0):
raise Exception(f'{ScriptName} requires docker edge, please see {DOCKER_INSTALL_URLS[self.platform]}')
elif (err != 0):
raise Exception(f'{ScriptName} requires docker edge, please see {DOCKER_INSTALL_URLS[self.platform]}')
# tweak CPU/RAM usage for Docker in Mac
settingsFile = MAC_BREW_DOCKER_SETTINGS.format(self.scriptUser)
if result and os.path.isfile(settingsFile) and InstallerYesOrNo(f'Configure Docker resource usage in {settingsFile}?', default=True):
# adjust CPU and RAM based on system resources
if self.totalCores >= 16:
newCpus = 12
elif self.totalCores >= 12:
newCpus = 8
elif self.totalCores >= 8:
newCpus = 6
elif self.totalCores >= 4:
newCpus = 4
else:
newCpus = 2
if self.totalMemoryGigs >= 64.0:
newMemoryGiB = 32
elif self.totalMemoryGigs >= 32.0:
newMemoryGiB = 24
elif self.totalMemoryGigs >= 24.0:
newMemoryGiB = 16
elif self.totalMemoryGigs >= 16.0:
newMemoryGiB = 12
elif self.totalMemoryGigs >= 8.0:
newMemoryGiB = 8
elif self.totalMemoryGigs >= 4.0:
newMemoryGiB = 4
else:
newMemoryGiB = 2
while not InstallerYesOrNo(f"Setting {newCpus if newCpus else '(unchanged)'} for CPU cores and {newMemoryGiB if newMemoryGiB else '(unchanged)'} GiB for RAM. Is this OK?", default=True):
newCpus = InstallerAskForString('Enter Docker CPU cores (e.g., 4, 8, 16)')
newMemoryGiB = InstallerAskForString('Enter Docker RAM MiB (e.g., 8, 16, etc.)')
if newCpus or newMemoryMiB:
with open(settingsFile, 'r+') as f:
data = json.load(f)
if newCpus: data['cpus'] = int(newCpus)
if newMemoryGiB: data['memoryMiB'] = int(newMemoryGiB)*1024
f.seek(0)
json.dump(data, f, indent=2)
f.truncate()
# at this point we need to essentially update our system memory stats because we're running inside docker
# and don't have the whole banana at our disposal
self.totalMemoryGigs = newMemoryGiB
eprint("Docker resource settings adjusted, attempting restart...")
err, out = self.run_process(['osascript', '-e', 'quit app "Docker"'])
if (err == 0):
time.sleep(5)
err, out = self.run_process(['open', '-a', 'Docker'])
if (err == 0):
err, out = self.run_process(['docker', 'info'], retry=12, retrySleepSec=5)
if (err == 0):
if self.debug:
eprint('"docker info" succeeded')
else:
eprint(f"Restarting Docker automatically failed: {out}")
while True:
response = InstallerAskForString('Please restart Docker via the system taskbar, then return here and type YES').lower()
if (response == 'yes'):
break
return result
###################################################################################################
# main
def main():
global args
# extract arguments from the command line
# print (sys.argv[1:]);
parser = argparse.ArgumentParser(description='Malcolm install script', add_help=False, usage=f'{ScriptName} <arguments>')
parser.add_argument('-v', '--verbose', dest='debug', type=str2bool, nargs='?', const=True, default=False, help="Verbose output")
parser.add_argument('-m', '--malcolm-file', required=False, dest='mfile', metavar='<STR>', type=str, default='', help='Malcolm .tar.gz file for installation')
parser.add_argument('-i', '--image-file', required=False, dest='ifile', metavar='<STR>', type=str, default='', help='Malcolm docker images .tar.gz file for installation')
parser.add_argument('-c', '--configure', dest='configOnly', type=str2bool, nargs='?', const=True, default=False, help="Only do configuration (not installation)")
parser.add_argument('-f', '--configure-file', required=False, dest='configFile', metavar='<STR>', type=str, default='', help='Single docker-compose YML file to configure')
parser.add_argument('-d', '--defaults', dest='acceptDefaults', type=str2bool, nargs='?', const=True, default=False, help="Accept defaults to prompts without user interaction")
parser.add_argument('-l', '--logstash-expose', dest='exposeLogstash', type=str2bool, nargs='?', const=True, default=False, help="Expose Logstash port to external hosts")
parser.add_argument('-e', '--elasticsearch-expose', dest='exposeElastic', type=str2bool, nargs='?', const=True, default=False, help="Expose Elasticsearch port to external hosts")
parser.add_argument('-r', '--restart-malcolm', dest='malcolmAutoRestart', type=str2bool, nargs='?', const=True, default=False, help="Restart Malcolm on system restart (unless-stopped)")
try:
parser.error = parser.exit
args = parser.parse_args()
except SystemExit:
parser.print_help()
exit(2)
if args.debug:
eprint(os.path.join(ScriptPath, ScriptName))
eprint(f"Arguments: {sys.argv[1:]}")
eprint(f"Arguments: {args}")
else:
sys.tracebacklimit = 0
if not ImportRequests(debug=args.debug):
exit(2)
# If Malcolm and images tarballs are provided, we will use them.
# If they are not provided, look in the pwd first, then in the script directory, to see if we
# can locate the most recent tarballs
malcolmFile = None
imageFile = None
if args.mfile and os.path.isfile(args.mfile):
malcolmFile = args.mfile
else:
# find the most recent non-image tarball, first checking in the pwd then in the script path
files = list(filter(lambda x: "_images" not in x, glob.glob(os.path.join(origPath, '*.tar.gz'))))
if (len(files) == 0):
files = list(filter(lambda x: "_images" not in x, glob.glob(os.path.join(ScriptPath, '*.tar.gz'))))
files.sort(key=lambda x: os.path.getmtime(x), reverse=True)
if (len(files) > 0):
malcolmFile = files[0]
if args.ifile and os.path.isfile(args.ifile):
imageFile = args.ifile
if (malcolmFile and os.path.isfile(malcolmFile)) and (not imageFile or not os.path.isfile(imageFile)):
# if we've figured out the malcolm tarball, the _images tarball should match it
imageFile = malcolmFile.replace('.tar.gz', '_images.tar.gz')
if not os.path.isfile(imageFile): imageFile = None
if args.debug:
if args.configOnly:
eprint("Only doing configuration, not installation")
else:
eprint(f"Malcolm install file: {malcolmFile}")
eprint(f"Docker images file: {imageFile}")
installerPlatform = platform.system()
if installerPlatform == PLATFORM_LINUX:
installer = LinuxInstaller(debug=args.debug, configOnly=args.configOnly)
elif installerPlatform == PLATFORM_MAC:
installer = MacInstaller(debug=args.debug, configOnly=args.configOnly)
elif installerPlatform == PLATFORM_WINDOWS:
raise Exception(f'{ScriptName} is not yet supported on {installerPlatform}')
installer = WindowsInstaller(debug=args.debug, configOnly=args.configOnly)
success = False
installPath = None
if (not args.configOnly):
if hasattr(installer, 'install_required_packages'): success = installer.install_required_packages()
if hasattr(installer, 'install_docker'): success = installer.install_docker()
if hasattr(installer, 'install_docker_compose'): success = installer.install_docker_compose()
if hasattr(installer, 'tweak_system_files'): success = installer.tweak_system_files()
if hasattr(installer, 'install_docker_images'): success = installer.install_docker_images(imageFile)
if args.configOnly or (args.configFile and os.path.isfile(args.configFile)):
if not args.configFile:
for testPath in [origPath, ScriptPath, os.path.realpath(os.path.join(ScriptPath, ".."))]:
if os.path.isfile(os.path.join(testPath, "docker-compose.yml")):
installPath = testPath
else:
installPath = os.path.dirname(os.path.realpath(args.configFile))
success = (installPath is not None) and os.path.isdir(installPath)
if args.debug:
eprint(f"Malcolm installation detected at {installPath}")
elif hasattr(installer, 'install_malcolm_files'):
success, installPath = installer.install_malcolm_files(malcolmFile)
if (installPath is not None) and os.path.isdir(installPath) and hasattr(installer, 'tweak_malcolm_runtime'):
installer.tweak_malcolm_runtime(installPath, expose_elastic_default=args.exposeElastic, expose_logstash_default=args.exposeLogstash, restart_mode_default=args.malcolmAutoRestart)
eprint(f"\nMalcolm has been installed to {installPath}. See README.md for more information.")
eprint(f"Scripts for starting and stopping Malcolm and changing authentication-related settings can be found in {os.path.join(installPath, 'scripts')}.")
if __name__ == '__main__':
main()