diff --git a/.gitignore b/.gitignore index 401f18b..4e9679f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__ *.pyc static/static_root imagebuilder +images/ diff --git a/device-config/2/bin/heartbeat b/device-config/2/bin/heartbeat index 4df0141..6b078cd 100755 --- a/device-config/2/bin/heartbeat +++ b/device-config/2/bin/heartbeat @@ -1,59 +1,119 @@ +# Get IP address on VPN bridge interface through some arcane magic ipaddr(){ if="${1:-br-VPN360}" result=$(/sbin/ip -o -4 addr show dev "${if}" 2&>/dev/null | /bin/sed 's/^.*inet // ; s/\/...*$//') /usr/bin/printf %s "${result}" } +# Disable (broadcast) WiFi device stopwifi(){ /sbin/uci set wireless.radio0.disabled=1 - /sbin/uci commit + /sbin/uci commit } +# Enable (broadcast) WiFi device startwifi(){ /sbin/uci set wireless.radio0.disabled=0 /sbin/uci commit - /sbin/wifi + /sbin/wifi } +# Disable and re-enable (broadcast) WiFi device restartwifi(){ stopwifi startwifi } -. /etc/vpnsecret +# Set power LED brightness +powerled(){ + echo $1 > /sys/devices/platform/leds-gpio/leds/gl-ar750:white:power/brightness +} +# Set second LED brightness +led2g(){ + echo $1 > /sys/devices/platform/leds-gpio/leds/gl-ar750:white:wlan2g/brightness +} + +# Set third LED brightness +led5g(){ + echo $1 > /sys/devices/platform/leds-gpio/leds/gl-ar750:white:wlan5g/brightness +} + +. /etc/vpnsecret # Source the server authentication secret + +# Retrieve hosts file from server /usr/bin/wget -O/etc/hosts https://admin360.kumi.host/hosts --post-data "secret=$SECRET" --no-check-certificate >/var/log/wget 2>&1 +# Prepare for default VPN-WiFi bridge /sbin/uci set wireless.@wifi-iface[0].network="VPN360" /sbin/uci commit + +# Disable WiFi for as long as there is no bridge providing IP addresses stopwifi +# Turn off all LEDs +powerled 0 +led2g 0 +led5g 0 + +# Launch VPN client /usr/sbin/openvpn /etc/openvpn/client.conf >/var/log/openvpn & -/bin/sleep 60 +# Try for approx. 1 minute to get an IP from the VPN before falling back to DHCP +counter=0 -if [ $(ipaddr) ] - then - startwifi - while [ True ] - do - sleep 10 - if [ $(ipaddr) ] - then - /usr/bin/wget -O- https://admin360.kumi.host/heartbeat --post-data "secret=$SECRET&ip=$(ipaddr)" --no-check-certificate 2>/var/log/wget | /bin/ash - fi - done - else - /sbin/uci set wireless.@wifi-iface[0].network="DHCP" - /sbin/uci commit - startwifi - /sbin/ip a add 192.168.36.1/24 dev br-DHCP - /sbin/ifconfig br-DHCP down - /sbin/ifconfig br-DHCP up - while [ True ] - do - sleep 10 - /usr/bin/wget -O- https://admin360.kumi.host/heartbeat --post-data "secret=$SECRET" --no-check-certificate 2>/var/log/wget | /bin/ash - done -fi +while [ $counter -lt 60 ] + do + powerled $(( counter % 2 )) # Make power LED flash + if [ $(ipaddr) ] # = If connection to the server is working + then + # Turn on LEDs indicating boot completion and connection success + powerled 1 + led5g 1 + + # Enable WiFi as the VPN bridge is now functional + startwifi + + # Send a heartbeat to the server every 10 seconds + # This is also used to transfer commands from the server to the device + while [ True ] + do + /bin/sleep 10 + + # Let's hope there is an IP address on the VPN interface + # If not, this might be a temporary issue (lost network connection or lease expiration) + # We assume that users will reboot the device if it doesn't work for extended periods of time + if [ $(ipaddr) ] + then + /usr/bin/wget -O- https://admin360.kumi.host/heartbeat --post-data "secret=$SECRET&ip=$(ipaddr)" --no-check-certificate 2>/var/log/wget | /bin/ash + fi + done + fi + counter=$(( counter + 1 )) + /bin/sleep 1 # Wait for a second before re-trying + done + +# We should only ever get to this point if no VPN connection was established within a minute + +# Switch WiFi device to the DHCP bridge +/sbin/uci set wireless.@wifi-iface[0].network="DHCP" +/sbin/uci commit + +# Turn on LEDs indicating connection failure and DHCP fallback +powerled 1 +led2g 1 + +# Start WiFi device now bridged to the DHCP and assign server IP +startwifi +/sbin/ip a add 192.168.36.1/24 dev br-DHCP +/sbin/ifconfig br-DHCP down +/sbin/ifconfig br-DHCP up + +# Send a heartbeat to the server every 10 seconds +# This is also used to transfer commands from the server to the device +while [ True ] + do + sleep 10 + /usr/bin/wget -O- https://admin360.kumi.host/heartbeat --post-data "secret=$SECRET" --no-check-certificate 2>/var/log/wget | /bin/ash + done diff --git a/device-config/2/etc/config/system b/device-config/2/etc/config/system index d2e40fc..721f31c 100644 --- a/device-config/2/etc/config/system +++ b/device-config/2/etc/config/system @@ -17,19 +17,3 @@ config timeserver 'ntp' list server '2.openwrt.pool.ntp.org' list server '3.openwrt.pool.ntp.org' -config led - option trigger 'netdev' - option dev 'br-lan' - option mode 'link' - option name 'WAN' - option sysfs 'gl-ar300m:green:lan' - option default '0' - -config led - option name 'VPN' - option trigger 'netdev' - option dev 'br-VPN360' - option mode 'link' - option sysfs 'gl-ar300m:green:wlan' - option default '0' - diff --git a/manager/migrations/0018_auto_20181225_2225.py b/manager/migrations/0018_auto_20181225_2225.py new file mode 100644 index 0000000..dd14a1d --- /dev/null +++ b/manager/migrations/0018_auto_20181225_2225.py @@ -0,0 +1,29 @@ +# Generated by Django 2.1.3 on 2018-12-25 22:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('manager', '0017_auto_20181223_0936'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='firmware', + field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Firmware Creation Time'), + ), + migrations.AddField( + model_name='model', + name='firmware', + field=models.DateTimeField(auto_now=True, verbose_name='Firmware Modification Date'), + ), + migrations.AlterField( + model_name='device', + name='wifi', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='manager.Wifi'), + ), + ] diff --git a/manager/migrations/0019_auto_20181225_2228.py b/manager/migrations/0019_auto_20181225_2228.py new file mode 100644 index 0000000..30aad85 --- /dev/null +++ b/manager/migrations/0019_auto_20181225_2228.py @@ -0,0 +1,29 @@ +# Generated by Django 2.1.3 on 2018-12-25 22:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('manager', '0018_auto_20181225_2225'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='lastbeat', + field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Last Received Timestamp'), + ), + migrations.AlterField( + model_name='device', + name='lasttime', + field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Last Received IP'), + ), + migrations.AlterField( + model_name='device', + name='wifi', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='manager.Wifi'), + ), + ] diff --git a/manager/migrations/0020_auto_20181225_2228.py b/manager/migrations/0020_auto_20181225_2228.py new file mode 100644 index 0000000..0c29d0a --- /dev/null +++ b/manager/migrations/0020_auto_20181225_2228.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.3 on 2018-12-25 22:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('manager', '0019_auto_20181225_2228'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='wifi', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='manager.Wifi'), + ), + ] diff --git a/manager/migrations/0021_auto_20181225_2230.py b/manager/migrations/0021_auto_20181225_2230.py new file mode 100644 index 0000000..7222e4d --- /dev/null +++ b/manager/migrations/0021_auto_20181225_2230.py @@ -0,0 +1,24 @@ +# Generated by Django 2.1.3 on 2018-12-25 22:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('manager', '0020_auto_20181225_2228'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='update', + field=models.BooleanField(default=False, verbose_name='Trigger Firmware Update'), + ), + migrations.AlterField( + model_name='device', + name='wifi', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='manager.Wifi'), + ), + ] diff --git a/manager/models.py b/manager/models.py index 019921d..5d6fcc4 100644 --- a/manager/models.py +++ b/manager/models.py @@ -24,6 +24,7 @@ class Model(models.Model): name = models.CharField("Model Name", max_length=100, unique=True) extname = models.CharField("Manufacturer Model Name", max_length=100) config = models.TextField("OpenWRT Compile Config", blank=True, null=True) + firmware = models.DateTimeField("Firmware Modification Date", auto_now=True) def __str__(self): return self.name @@ -48,11 +49,13 @@ class Device(models.Model): network = models.ForeignKey(Network, on_delete=models.SET_NULL, blank=True, null=True) wifi = models.OneToOneField(Wifi, on_delete=models.SET_NULL, blank=True, null=True) curip = models.CharField("Current IP Address", max_length=15, blank=True, null=True) - lasttime = models.DateTimeField("Last Received IP", blank=True, null=True) - lastbeat = models.DateTimeField("Last Received Timestamp", blank=True, null=True) + lasttime = models.DateTimeField("Last Received IP", blank=True, null=True, editable=False) + lastbeat = models.DateTimeField("Last Received Timestamp", blank=True, null=True, editable=False) secret = models.CharField("Secret", default=getRandom, max_length=128) password = models.CharField("Device Password", default=getRandom, max_length=128) vpnconfig = models.TextField("VPN Configuration", blank=True, null=True, editable=False) + firmware = models.DateTimeField("Firmware Creation Time", blank=True, null=True, editable=False) + update = models.BooleanField("Trigger Firmware Update", default=False) reboot = models.BooleanField("Trigger Reboot", default=False) def __str__(self): diff --git a/manager/views.py b/manager/views.py index 7e0823f..b31736a 100644 --- a/manager/views.py +++ b/manager/views.py @@ -6,6 +6,7 @@ from django.views.decorators.csrf import csrf_exempt from django.utils import timezone from django.core.files import File from django.db.models.fields.files import FieldFile + from .models import Device, Organization, Network, Model from distutils.dir_util import copy_tree @@ -18,6 +19,7 @@ import socket import tempfile import crypt import tarfile +import datetime def index(request): if request.user.is_authenticated: @@ -47,6 +49,121 @@ def hosts(request): device.save() return render(request, "manager/hosts", {"device": device}) +def mkfirmware(device, path): + if device.firmware and device.firmware > device.model.firmware and glob.glob("%s/%s.bin" % (path, device.id)): + return True + + BEFORE = os.getcwd() + DEVICEDIR = "/opt/vpnmanager/device-config/%i/" % device.model.id + SRCDIR = "/opt/vpnmanager/imagebuilder/%i/" % device.model.id + + if glob.glob(SRCDIR + "/.kumilock"): + return False + + with open(SRCDIR + "/.kumilock", "w") as lock: + lock.write("") + + tempdir = tempfile.TemporaryDirectory() + + copy_tree(DEVICEDIR, tempdir.name) + + # Write OpenVPN config + + with open(tempdir.name + "/etc/openvpn/client.conf", "w") as vpnconf: + vpnconf.write(device.vpnconfig) + + # Write secret + + with open(tempdir.name + "/etc/vpnsecret", "w") as secret: + secret.write('SECRET="%s"' % device.secret) + + # Write password + + with open(tempdir.name + "/etc/shadow", "r") as shadow: + password = crypt.crypt(device.password, crypt.mksalt(crypt.METHOD_MD5)) + shadowin = shadow.read() + + with open(tempdir.name + "/etc/shadow", "w") as shadowout: + shadowout.write(shadowin.replace("$PASSWORD", password)) + + # Write SSID + + with open(tempdir.name + "/etc/config/wireless", "r") as wireless: + wirein = wireless.read() + + with open(tempdir.name + "/etc/config/wireless", "w") as wireout: + wire = wirein.replace("$SSID", device.serial) + + try: + wire2 = wire.replace("$WIFISSID", device.wifi.ssid) + wire2 = wire2.replace("$WIFIKEY", device.wifi.key) + except: + wire2 = wire.replace("$WIFISSID", "NoSuchWiFi") + wire2 = wire2.replace("$WIFIKEY", "NoSuchKey") + + wireout.write(wire2) + + + ''' + # Generate .tar.gz file + + with tarfile.open(tempdir.name + ".tar.gz", "w:gz") as tar: + tar.add(tempdir.name, arcname=os.path.sep) + + with open(tempdir.name + ".tar.gz", "rb") as download: + response = HttpResponse(download.read(), content_type="application/tar+gzip") + response['Content-Disposition'] = 'inline; filename=' + os.path.basename(device.serial + ".tar.gz") + return response + ''' + + # Create compilation environment + + os.system("rm -rf " + SRCDIR + "/files/") + os.mkdir(SRCDIR + "/files/") + os.system("cp -r " + tempdir.name + "/* " + SRCDIR + "/files/") + + tempdir.cleanup() + + os.system("rm " + SRCDIR + "/bin/targets/ar71xx/generic/*") + + # Build image + + os.chdir(SRCDIR) + + try: + subprocess.call(["/usr/bin/make"]) + except: + os.remove(SRCDIR + "/.kumilock") + os.chdir(BEFORE) + return False + + os.chdir(BEFORE) + + os.rename(glob.glob(SRCDIR + "/bin/targets/ar71xx/generic/*squashfs-sysupgrade.bin")[0], "%s/%s.bin" % (path, device.id)) + os.remove(SRCDIR + "/.kumilock") + os.system("rm -rf " + SRCDIR + "/files/") + os.system("rm " + SRCDIR + "/bin/targets/ar71xx/generic/*") + device.firmware = datetime.datetime.now() + device.save() + return True + +@csrf_exempt +def update(request): + FWDIR = "/opt/vpnmanager/images/" + + device = get_object_or_404(Device, secret=request.POST.get("secret", "")) + + if not mkfirmware(device, FWDIR): + return HttpResponse(status=503) + + device.update = False + device.save() + + with open("%s/%s.bin" % (FWDIR, device.id), "rb") as download: + response = HttpResponse(download.read(), content_type="application/octet-stream") + response['Content-Disposition'] = 'inline; filename=%s.bin' % device.serial + return response + def ping(request, device_id): if request.user.is_authenticated: device = None @@ -141,103 +258,22 @@ def editdevice(request, device_id): return redirect("/") def getconfig(request, device_id): + FWDIR = "/opt/vpnmanager/images/" + if not request.user.is_superuser: return redirect("/") device = get_object_or_404(Device, id=device_id) - BEFORE = os.getcwd() - DEVICEDIR = "/opt/vpnmanager/device-config/%i/" % device.model.id - SRCDIR = "/opt/vpnmanager/imagebuilder/%i/" % device.model.id - - if glob.glob(SRCDIR + "/.kumilock"): - return HttpResponse("Another download is being prepared right now. Please wait for it to finish and try again later.") - - with open(SRCDIR + "/.kumilock", "w") as lock: - lock.write("") - - tempdir = tempfile.TemporaryDirectory() - - copy_tree(DEVICEDIR, tempdir.name) - - # Write OpenVPN config - - with open(tempdir.name + "/etc/openvpn/client.conf", "w") as vpnconf: - vpnconf.write(device.vpnconfig) - - # Write secret - - with open(tempdir.name + "/etc/vpnsecret", "w") as secret: - secret.write('SECRET="%s"' % device.secret) - - # Write password - - with open(tempdir.name + "/etc/shadow", "r") as shadow: - password = crypt.crypt(device.password, crypt.mksalt(crypt.METHOD_MD5)) - shadowin = shadow.read() - - with open(tempdir.name + "/etc/shadow", "w") as shadowout: - shadowout.write(shadowin.replace("$PASSWORD", password)) - - # Write SSID - - with open(tempdir.name + "/etc/config/wireless", "r") as wireless: - wirein = wireless.read() - - with open(tempdir.name + "/etc/config/wireless", "w") as wireout: - wire = wirein.replace("$SSID", device.serial) - - try: - wire2 = wire.replace("$WIFISSID", device.wifi.ssid) - wire2 = wire2.replace("$WIFIKEY", device.wifi.key) - except: - wire2 = wire.replace("$WIFISSID", "NoSuchWiFi") - wire2 = wire2.replace("$WIFIKEY", "NoSuchKey") - - wireout.write(wire2) + if not mkfirmware(device, FWDIR): + return HttpResponse("Something went wrong generating the firmware image. The server may be busy, please try again later.") + device.update = False + device.save() - ''' - # Generate .tar.gz file - - with tarfile.open(tempdir.name + ".tar.gz", "w:gz") as tar: - tar.add(tempdir.name, arcname=os.path.sep) - - with open(tempdir.name + ".tar.gz", "rb") as download: - response = HttpResponse(download.read(), content_type="application/tar+gzip") - response['Content-Disposition'] = 'inline; filename=' + os.path.basename(device.serial + ".tar.gz") - return response - ''' - - # Create compilation environment - - os.system("rm -rf " + SRCDIR + "/files/") - os.mkdir(SRCDIR + "/files/") - os.system("cp -r " + tempdir.name + "/* " + SRCDIR + "/files/") - - tempdir.cleanup() - - os.system("rm " + SRCDIR + "/bin/targets/ar71xx/generic/*") - - # Build image - - os.chdir(SRCDIR) - - try: - subprocess.call(["/usr/bin/make"]) - except: - os.remove(SRCDIR + "/.kumilock") - os.chdir(BEFORE) - return HttpResponse("Something went wrong building the image file.") - - os.chdir(BEFORE) - - with open(glob.glob(SRCDIR + "/bin/targets/ar71xx/generic/*squashfs-sysupgrade.bin")[0], "rb") as download: + with open("%s/%s.bin" % (FWDIR, device.id), "rb") as download: response = HttpResponse(download.read(), content_type="application/octet-stream") - response['Content-Disposition'] = 'inline; filename=' + os.path.basename(device.serial + ".bin") - os.remove(SRCDIR + "/.kumilock") - os.system("rm -rf " + SRCDIR + "/files/") - os.system("rm " + SRCDIR + "/bin/targets/ar71xx/generic/*") + response['Content-Disposition'] = 'inline; filename=%s.bin' % device.serial return response def rebootdevice(request, device_id): @@ -246,13 +282,23 @@ def rebootdevice(request, device_id): for organization in Organization.objects.filter(users=request.user): device = device or Device.objects.filter(id=device_id, organization=organization) - if not device: - return redirect("/") + if device: + device[0].reboot = True + device[0].save() - device[0].reboot = True - device[0].save() + return redirect("/") - return redirect("/") +def updatedevice(request, device_id): + if request.user.is_staff: + device = None + for organization in Organization.objects.filter(users=request.user): + device = device or Device.objects.filter(id=device_id, organization=organization) + + if device: + device[0].update = True + device[0].save() + + return redirect("/") def deletedevice(request, device_id): if request.user.is_superuser: