import os import re import random import string import subprocess import warnings import logging from tempfile import mkdtemp from datetime import datetime from functools import reduce try: from urllib import urlencode except ImportError: from urllib.parse import urlencode from hetzner import RobotError, WebRobotError from hetzner.rdns import ReverseDNS, ReverseDNSManager from hetzner.reset import Reset from hetzner.util import addr, scraping __all__ = ['AdminAccount', 'IpAddress', 'RescueSystem', 'Server', 'Subnet', 'IpManager', 'SubnetManager'] class SSHAskPassHelper(object): """ This creates a temporary SSH askpass helper script, which just passes the provided password. """ def __init__(self, passwd): self.passwd = passwd self.tempdir = None self.script = None def __enter__(self): self.tempdir = mkdtemp() script = os.path.join(self.tempdir, "askpass") fd = os.open(script, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o700) self.script = script esc_passwd = self.passwd.replace("'", r"'\''") askpass = "#!/bin/sh\necho -n '{0}'".format(esc_passwd).encode('ascii') os.write(fd, askpass) os.close(fd) return script def __exit__(self, type, value, traceback): if self.script is not None: os.unlink(self.script) if self.tempdir is not None: os.rmdir(self.tempdir) class RescueSystem(object): def __init__(self, server): self.server = server self.conn = server.conn self._active = None self._password = None def _fetch_status(self): reply = self.conn.get('/boot/{0}/rescue'.format(self.server.ip)) data = reply['rescue'] self._active = data['active'] self._password = data['password'] @property def active(self): if self._active is not None: return self._active self._fetch_status() return self._active @property def password(self): if self._password is not None: return self._password self._fetch_status() return self._password def _rescue_action(self, method, opts=None): reply = self.conn.request( method, '/boot/{0}/rescue'.format(self.server.ip), opts ) data = reply['rescue'] self._active = data['active'] self._password = data['password'] def activate(self, bits=64, os='linux'): """ Activate the rescue system if necessary. """ if not self.active: opts = {'os': os, 'arch': bits} return self._rescue_action('post', opts) def deactivate(self): """ Deactivate the rescue system if necessary. """ if self.active: return self._rescue_action('delete') def observed_activate(self, *args, **kwargs): """ Activate the rescue system and reboot into it. Look at Server.observed_reboot() for options. """ self.activate() self.server.observed_reboot(*args, **kwargs) def observed_deactivate(self, *args, **kwargs): """ Deactivate the rescue system and reboot into normal system. Look at Server.observed_reboot() for options. """ self.deactivate() self.server.observed_reboot(*args, **kwargs) def shell(self, *args, **kwargs): """ Reboot into rescue system, spawn a shell and after the shell is closed, reboot back into the normal system. Look at Server.observed_reboot() for further options. """ msg = ("The RescueSystem.shell() method will be removed from the API" " in version 1.0.0, please do not use it! See" " https://github.com/aszlig/hetzner/issues/13" " for details.") warnings.warn(msg, FutureWarning) self.observed_activate(*args, **kwargs) with SSHAskPassHelper(self.password) as askpass: ssh_options = [ 'CheckHostIP=no', 'GlobalKnownHostsFile=/dev/null', 'UserKnownHostsFile=/dev/null', 'StrictHostKeyChecking=no', 'LogLevel=quiet', ] ssh_args = reduce(lambda acc, opt: acc + ['-o', opt], ssh_options, []) cmd = ['ssh'] + ssh_args + ["root@{0}".format(self.server.ip)] env = dict(os.environ) env['DISPLAY'] = ":666" env['SSH_ASKPASS'] = askpass subprocess.check_call(cmd, env=env, preexec_fn=os.setsid) self.observed_deactivate(*args, **kwargs) class AdminAccount(object): def __init__(self, server): # XXX: This is preliminary, because we don't have such functionality in # the official API yet. self._scraper = server.conn.scraper self._serverid = server.number self.exists = False self.login = None self.passwd = None self.update_info() def update_info(self): """ Get information about currently active admin login. """ self._scraper.login() login_re = re.compile(r'"label_req">Login.*?"element">([^<]+)', re.DOTALL) path = '/server/admin/id/{0}'.format(self._serverid) response = self._scraper.request(path) assert response.status == 200 match = login_re.search(response.read().decode('utf-8')) if match is None: self.exists = False else: self.exists = True self.login = match.group(1) def _genpasswd(self): random.seed(os.urandom(512)) chars = string.ascii_letters + string.digits + "/()-=+_,;.^~#*@" length = random.randint(20, 40) return ''.join(random.choice(chars) for i in range(length)) def create(self, passwd=None): """ Create a new admin account if missing. If passwd is supplied, use it instead of generating a random one. """ if passwd is None: passwd = self._genpasswd() form_path = '/server/admin/id/{0}'.format(self._serverid) form_response = self._scraper.request(form_path, method='POST') parser = scraping.CSRFParser('password[_csrf_token]') parser.feed(form_response.read().decode('utf-8')) assert parser.csrf_token is not None data = { 'password[new_password]': passwd, 'password[new_password_repeat]': passwd, 'password[_csrf_token]': parser.csrf_token, } if not self.exists: failmsg = "Unable to create admin account" path = '/server/adminCreate/id/{0}'.format(self._serverid) else: failmsg = "Unable to update admin account password" path = '/server/adminUpdate' data['id'] = self._serverid response = self._scraper.request(path, data) data = response.read().decode('utf-8') if "msgbox_success" not in data: ul_re = re.compile(r'(.*?)', re.DOTALL) li_re = re.compile(r'
  • \s*([^<]*?)\s*
  • ') ul_match = ul_re.search(data) if ul_match is not None: errors = [error.group(1) for error in li_re.finditer(ul_match.group(0))] msg = failmsg + ': ' + ', '.join(errors) raise WebRobotError(msg) raise WebRobotError(failmsg) self.update_info() self.passwd = passwd return self.login, self.passwd def delete(self): """ Remove the admin account. """ if not self.exists: return path = '/server/adminDelete/id/{0}'.format(self._serverid) assert "msgbox_success" in \ self._scraper.request(path).read().decode('utf-8') self.update_info() def __repr__(self): if self.exists: return "".format(self.login) else: return "" class IpAddress(object): def __init__(self, conn, result, subnet_ip=None): self.conn = conn self.subnet_ip = subnet_ip self.update_info(result) self._rdns = None @property def rdns(self): """ Get or set reverse DNS PTRs. """ if self._rdns is None: self._rdns = ReverseDNS(self.conn, self.ip) return self._rdns def update_info(self, result=None): """ Update the information of the current IP address and all related information such as traffic warnings. If result is omitted, a new request is sent to the robot to gather the information. """ if self.subnet_ip is not None: if result is None: result = self.conn.get('/subnet/{0}'.format(self._subnet_addr)) data = result['subnet'] self._subnet_addr = data['ip'] data['ip'] = self.subnet_ip # Does not exist in subnets data['separate_mac'] = None else: if result is None: result = self.conn.get('/ip/{0}'.format(self.ip)) data = result['ip'] self.ip = data['ip'] self.server_ip = data['server_ip'] self.locked = data['locked'] self.separate_mac = data['separate_mac'] self.traffic_warnings = data['traffic_warnings'] self.traffic_hourly = data['traffic_hourly'] self.traffic_daily = data['traffic_daily'] self.traffic_monthly = data['traffic_monthly'] def __repr__(self): return "".format(self.ip) class IpManager(object): def __init__(self, conn, main_ip): self.conn = conn self.main_ip = main_ip def get(self, ip): """ Get a specific IP address of a server. """ return IpAddress(self.conn, self.conn.get('/ip/{0}'.format(ip))) def __iter__(self): data = urlencode({'server_ip': self.main_ip}) result = self.conn.get('/ip?{0}'.format(data)) return iter([IpAddress(self.conn, ip) for ip in result]) class Subnet(object): def __init__(self, conn, result): self.conn = conn self.update_info(result) def update_info(self, result=None): """ Update the information of the subnet. If result is omitted, a new request is sent to the robot to gather the information. """ if result is None: result = self.conn.get('/subnet/{0}'.format(self.net_ip)) data = result['subnet'] self.net_ip = data['ip'] self.mask = data['mask'] self.gateway = data['gateway'] self.server_ip = data['server_ip'] self.failover = data['failover'] self.locked = data['locked'] self.traffic_warnings = data['traffic_warnings'] self.traffic_hourly = data['traffic_hourly'] self.traffic_daily = data['traffic_daily'] self.traffic_monthly = data['traffic_monthly'] self.is_ipv6, self.numeric_net_ip = addr.parse_ipaddr(self.net_ip) self.numeric_gateway = addr.parse_ipaddr(self.gateway, self.is_ipv6) getrange = addr.get_ipv6_range if self.is_ipv6 else addr.get_ipv4_range self.numeric_range = getrange(self.numeric_net_ip, self.mask) def get_ip_range(self): """ Return the smallest and biggest possible IP address of the current subnet. """ convert = addr.ipv6_bin2addr if self.is_ipv6 else addr.ipv4_bin2addr return convert(self.numeric_range[0]), convert(self.numeric_range[1]) def __contains__(self, addr): """ Check whether a specific IP address is within the current subnet. """ numeric_addr = addr.parse_ipaddr(addr, self.is_ipv6) return self.numeric_range[0] <= numeric_addr <= self.numeric_range[1] def get_ip(self, addr): """ Return an IpAddress object for the specified IPv4 or IPv6 address or None if the IP address doesn't exist in the current subnet. """ if addr in self: result = self.conn.get('/subnet/{0}'.format(self.net_ip)) return IpAddress(self.conn, result, addr) else: return None def __repr__(self): return "".format(self.net_ip, self.mask, self.gateway) class SubnetManager(object): def __init__(self, conn, main_ip): self.conn = conn self.main_ip = main_ip def get(self, net_ip): """ Get a specific subnet of a server. """ return Subnet(self.conn, self.conn.get('/subnet/{0}'.format(net_ip))) def __iter__(self): data = urlencode({'server_ip': self.main_ip}) try: result = self.conn.get('/subnet?{0}'.format(data)) except RobotError as err: # If there are no subnets a 404 is returned rather than just an # empty list. if err.status == 404: result = [] return iter([Subnet(self.conn, net) for net in result]) class Server(object): def __init__(self, conn, result): self.conn = conn self.update_info(result) self.rescue = RescueSystem(self) self.reset = Reset(self) self.ips = IpManager(self.conn, self.ip) self.subnets = SubnetManager(self.conn, self.ip) self.rdns = ReverseDNSManager(self.conn, self.ip) self._admin_account = None self.logger = logging.getLogger("Server #{0}".format(self.number)) @property def admin(self): """ Update, create and delete admin accounts. """ if self._admin_account is None: self._admin_account = AdminAccount(self) return self._admin_account def update_info(self, result=None): """ Updates the information of the current Server instance either by sending a new GET request or by parsing the response given by result. """ if result is None: result = self.conn.get('/server/{0}'.format(self.ip)) data = result['server'] self.ip = data['server_ip'] self.number = data['server_number'] self.name = data['server_name'] self.product = data['product'] self.datacenter = data['dc'] self.traffic = data['traffic'] self.status = data['status'] self.cancelled = data['cancelled'] self.paid_until = datetime.strptime(data['paid_until'], '%Y-%m-%d') self.is_vserver = self.product.startswith('VQ') def observed_reboot(self, *args, **kwargs): msg = ("Server.observed_reboot() is deprecated. Please use" " Server.reset.observed_reboot() instead.") warnings.warn(msg, DeprecationWarning) return self.reset.observed_reboot(*args, **kwargs) def reboot(self, *args, **kwargs): msg = ("Server.reboot() is deprecated. Please use" " Server.reset.reboot() instead.") warnings.warn(msg, DeprecationWarning) return self.reset.reboot(*args, **kwargs) def set_name(self, name): result = self.conn.post('/server/{0}'.format(self.ip), {'server_name': name}) self.update_info(result) def __repr__(self): return "<{0} (#{1} {2})>".format(self.ip, self.number, self.product)