468 lines
15 KiB
Python
468 lines
15 KiB
Python
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'<ul\s+class="error_list">(.*?)</ul>',
|
|
re.DOTALL)
|
|
li_re = re.compile(r'<li>\s*([^<]*?)\s*</li>')
|
|
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 "<AdminAccount login: {0}>".format(self.login)
|
|
else:
|
|
return "<AdminAccount missing>"
|
|
|
|
|
|
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 "<IpAddress {0}>".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 "<Subnet {0}/{1} (Gateway: {2})>".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)
|