first commit
This commit is contained in:
467
.venv/lib/python3.12/site-packages/hetzner/server.py
Normal file
467
.venv/lib/python3.12/site-packages/hetzner/server.py
Normal file
@@ -0,0 +1,467 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user