diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 00000000..e2dcb74e --- /dev/null +++ b/.github/workflows/master.yml @@ -0,0 +1,58 @@ +name: CI to Docker Hub + +on: + push: + tags: + - "v*.*.*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check Out Repo + uses: actions/checkout@v2 + + - name: Prepare tags + id: prep + run: | + DOCKER_IMAGE=${{ secrets.DOCKER_HUB_USERNAME }}/upsnap + VERSION=${GITHUB_REF#refs/tags/} + TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${VERSION:0:2},${DOCKER_IMAGE}:latest" + echo ::set-output name=tags::${TAGS} + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: ./ + file: ./Dockerfile + platforms: linux/amd64,linux/arm64,linux/arm/v7 + builder: ${{ steps.buildx.outputs.name }} + push: true + tags: ${{ steps.prep.outputs.tags }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/app/backend/backend/settings.py b/app/backend/backend/settings.py new file mode 100644 index 00000000..67cb01f4 --- /dev/null +++ b/app/backend/backend/settings.py @@ -0,0 +1,177 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 4.0.2. + +For more information on this file, see +https://docs.djangoproject.com/en/4.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.0/ref/settings/ +""" + +import os + +from django.core.management.utils import get_random_secret_key +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', get_random_secret_key()) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DJANGO_DEBUG', 'False') == 'True' + +ALLOWED_HOSTS = ["*"] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'channels', + 'django_celery_beat', + 'wol' +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'backend.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'backend.wsgi.application' +WSGI_APPLICATION = 'backend.wsgi.application' +ASGI_APPLICATION = 'backend.asgi.application' +CELERY_BROKER_URL = f"redis://{os.getenv('REDIS_HOST', '127.0.0.1')}:{os.getenv('REDIS_PORT', 6379)}" +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' + + +# Database +# https://docs.djangoproject.com/en/4.0/ref/settings/#databases + +if os.getenv("DB_TYPE") == "postgres": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": os.getenv("DB_NAME", "upsnap"), + "USER": os.getenv("DB_USER", "upsnap"), + "PASSWORD": os.getenv("DB_PASSWORD", "upsnap"), + "HOST": os.getenv("DB_HOST", "127.0.0.1"), + "PORT": os.getenv("DB_PORT", 5432), + "OPTIONS": { + "connect_timeout": 30 + } + } + } +elif os.getenv("DB_TYPE") == "mysql": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": os.getenv("DB_NAME", "upsnap"), + "USER": os.getenv("DB_USER", "upsnap"), + "PASSWORD": os.getenv("DB_PASSWORD", "upsnap"), + "HOST": os.getenv("DB_HOST", "127.0.0.1"), + "PORT": os.getenv("DB_PORT", 3306), + "OPTIONS": { + "connect_timeout": 30 + } + } + } +elif os.getenv("DB_TYPE") == "sqlite": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "/app/backend/db/db.sqlite3", + } + } + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [(os.getenv("REDIS_HOST", "127.0.0.1"), os.getenv("REDIS_PORT", 6379))], + "capacity": 1000, + "expiry": 10 + } + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.0/topics/i18n/ + +LANGUAGE_CODE = os.getenv("DJANGO_LANGUAGE_CODE", "en") + +TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "UTC") + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.0/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') + +# Default primary key field type +# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +VERSION = "v2.3.2" diff --git a/app/backend/wol/consumers.py b/app/backend/wol/consumers.py new file mode 100644 index 00000000..c811575b --- /dev/null +++ b/app/backend/wol/consumers.py @@ -0,0 +1,335 @@ +import ipaddress +import json +import os +import subprocess + +import pytz +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncWebsocketConsumer +from django_celery_beat.models import (CrontabSchedule, IntervalSchedule, + PeriodicTask) + +from wol.commands import shutdown, wake +from wol.models import Device, Port, Settings, Websocket + + +class WSConsumer(AsyncWebsocketConsumer): + async def connect(self): + await self.channel_layer.group_add("wol", self.channel_name) + await self.accept() + await self.send(text_data=json.dumps({ + "type": "init", + "message": { + "devices": await self.get_all_devices(), + "settings": await self.get_settings() + } + })) + await self.add_visitor() + await self.channel_layer.group_send( + "wol", { + "type": "send_group", + "message": { + "type": "visitor", + "message": await self.get_visitors() + } + } + ) + + async def disconnect(self, _): + await self.channel_layer.group_discard("wol", self.channel_name) + await self.remove_visitor() + await self.channel_layer.group_send( + "wol", { + "type": "send_group", + "message": { + "type": "visitor", + "message": await self.get_visitors() + } + } + ) + + async def receive(self, text_data=None): + received = json.loads(text_data) + if received["type"] == "wake": + await self.wake_device(received["id"]) + await self.channel_layer.group_send( + "wol", { + "type": "send_group", + "message": { + "type": "pending", + "message": received["id"] + } + } + ) + elif received["type"] == "shutdown": + await self.shutdown_device(received["id"]) + await self.channel_layer.group_send( + "wol", { + "type": "send_group", + "message": { + "type": "pending", + "message": received["id"] + } + } + ) + elif received["type"] == "delete_device": + await self.delete_device(received["id"]) + await self.channel_layer.group_send( + "wol", { + "type": "send_group", + "message": { + "type": "delete", + "message": received["id"] + } + } + ) + elif received["type"] == "update_device": + try: + await self.update_device(received["data"]) + await self.send(text_data=json.dumps({ + "type": "operationStatus", + "message": "Success" + })) + except Exception: + await self.send(text_data=json.dumps({ + "type": "operationStatus", + "message": "Error" + })) + elif received["type"] == "update_port": + await self.update_port(received["data"]) + elif received["type"] == "update_settings": + await self.update_settings(received["data"]) + elif received["type"] == "scan_network": + await self.send(text_data=json.dumps({ + "type": "scan_network", + "message": await self.scan_network() + })) + elif received["type"] == "backup": + await self.send(text_data=json.dumps({ + "type": "backup", + "message": await self.get_all_devices() + })) + + async def send_group(self, event): + await self.send(json.dumps(event["message"])) + + @database_sync_to_async + def add_visitor(self): + visitor_count = Websocket.objects.first() + visitor_count.visitors += 1 + visitor_count.save() + + @database_sync_to_async + def remove_visitor(self): + visitor_count = Websocket.objects.first() + visitor_count.visitors -= 1 + visitor_count.save() + + @database_sync_to_async + def get_visitors(self): + return Websocket.objects.first().visitors + + @database_sync_to_async + def wake_device(self, id): + dev = Device.objects.filter(id=id).first() + wake(dev.mac, dev.ip, dev.netmask) + + @database_sync_to_async + def shutdown_device(self, id): + dev = Device.objects.filter(id=id).first() + shutdown(dev.shutdown_cmd) + + @database_sync_to_async + def get_all_devices(self): + devices = Device.objects.all() + d = [] + for dev in devices: + data = { + "id": dev.id, + "name": dev.name, + "ip": dev.ip, + "mac": dev.mac, + "netmask": dev.netmask, + "link": dev.link, + "ports": [], + "wake": { + "enabled": False, + "cron": "" + }, + "shutdown": { + "enabled": False, + "cron": "", + "command": dev.shutdown_cmd + } + } + for p in dev.port.order_by("number"): + data["ports"].append({ + "number": p.number, + "name": p.name + }) + for action in ["wake", "shutdown"]: + try: + task = PeriodicTask.objects.filter( + name=f"{data['name']}-{action}", + task=f"wol.tasks.scheduled_{action}", crontab_id__isnull=False).get() + if task: + wake = CrontabSchedule.objects.get(id=task.crontab_id) + data[action]["enabled"] = task.enabled + data[action]["cron"] = " ".join( + [wake.minute, wake.hour, wake.day_of_month, wake.month_of_year, wake.day_of_week]) + except PeriodicTask.DoesNotExist: + pass + d.append(data) + return d + + @database_sync_to_async + def delete_device(self, id): + dev = Device.objects.get(id=id) + dev.delete() + + @database_sync_to_async + def update_device(self, data): + obj, _ = Device.objects.update_or_create( + mac=data["mac"], + defaults={ + "name": data["name"], + "ip": data["ip"], + "netmask": data["netmask"], + "link": data["link"] if data.get("link") else "", + "shutdown_cmd": data["shutdown"]["command"] if data.get("shutdown") else "" + } + ) + if data.get("ports"): + for port in data.get("ports"): + if port["checked"]: + p, _ = Port.objects.get_or_create( + number=port["number"], name=port["name"]) + obj.port.add(p) + else: + p = Port.objects.filter(number=port["number"]).first() + if p and p in obj.port.all(): + obj.port.remove(p) + + for action in ["wake", "shutdown"]: + if data.get(action): + if data[action]["enabled"]: + cron = data[action]["cron"].strip().split(" ") + if not len(cron) == 5: + return + minute, hour, dom, month, dow = cron + schedule, _ = CrontabSchedule.objects.get_or_create( + minute=minute, + hour=hour, + day_of_week=dow, + day_of_month=dom, + month_of_year=month, + timezone=pytz.timezone( + os.getenv("DJANGO_TIME_ZONE", "UTC")) + ) + PeriodicTask.objects.update_or_create( + name=f"{data['name']}-{action}", + defaults={ + "crontab": schedule, + "task": f"wol.tasks.scheduled_{action}", + "args": json.dumps([data["id"]]), + "enabled": True + } + ) + else: + for task in PeriodicTask.objects.filter(name=f"{data['name']}-{action}", task=f"wol.tasks.scheduled_{action}"): + task.enabled = False + task.save() + + @database_sync_to_async + def update_port(self, data): + if data.get("name"): + Port.objects.update_or_create( + number=data["number"], + defaults={ + "name": data["name"] + } + ) + else: + Port.objects.filter(number=data["number"]).delete() + + @database_sync_to_async + def get_settings(self): + conf = Settings.objects.get(id=1) + data = { + "discovery": conf.scan_address, + "interval": conf.interval, + "scan_network": [], + "notifications": conf.notifications + } + return data + + @database_sync_to_async + def update_settings(self, data): + if data["interval"] > 5: + data["interval"] = 5 + Settings.objects.update_or_create( + id=1, + defaults={ + "scan_address": data["discovery"], + "interval": data["interval"], + "notifications": data["notifications"] + } + ) + schedule, _ = IntervalSchedule.objects.get_or_create( + every=data["interval"], + period=IntervalSchedule.SECONDS, + ) + PeriodicTask.objects.update_or_create( + task="wol.tasks.ping_all_devices", + defaults={ + "interval": schedule + } + ) + + @database_sync_to_async + def scan_network(self): + data = [] + conf = Settings.objects.get(id=1) + netmask = str(ipaddress.ip_network(conf.scan_address).netmask) + + if not conf.scan_address: + return + + p = subprocess.Popen( + ["nmap", os.getenv("NMAP_ARGS", "-sP"), conf.scan_address], stdout=subprocess.PIPE) + out = p.communicate()[0].decode("utf-8") + ip_line = "Nmap scan report for" + mac_line = "MAC Address:" + + for line in out.splitlines(): + if line.startswith(ip_line): + line_splitted = line.split() + if len(line_splitted) == 6: + name = line_splitted[4] + ip = line_splitted[5] + ip = ip.replace("(", "") + ip = ip.replace(")", "") + else: + name = "Unknown" + ip = line_splitted[4] + elif line.startswith(mac_line): + line_splitted = line.split() + mac = line_splitted[2] + data.append({ + "name": name, + "ip": ip, + "netmask": netmask, + "mac": mac, + "link": "", + "ports": [], + "wake": { + "enabled": False, + "cron": "" + }, + "shutdown": { + "enabled": False, + "cron": "", + "command": "" + } + }) + return data