From 8ebf934f069ddc25f4b37195873f8569e15f6317 Mon Sep 17 00:00:00 2001 From: Aidaho Date: Sun, 3 Nov 2024 10:12:08 +0300 Subject: [PATCH] v8.1.2: Delete letsencrypt.sh script and add LetsEncrypt API endpoints Remove the letsencrypt.sh script and integrate LetsEncrypt functionality directly into the web application via new API endpoints. This includes creating, updating, retrieving, and deleting LetsEncrypt configurations, improving maintainability and user interaction with the LetsEncrypt feature. --- app/api/routes/routes.py | 3 + app/create_db.py | 9 +- app/modules/config/add.py | 40 -- app/modules/db/cred.py | 4 +- app/modules/db/db_model.py | 16 +- app/modules/db/ha_cluster.py | 12 +- app/modules/db/le.py | 54 +++ app/modules/roxywi/class_models.py | 50 ++- app/modules/server/server.py | 1 - app/modules/server/ssh.py | 6 +- app/modules/service/ha_cluster.py | 2 +- app/modules/service/installation.py | 11 +- app/routes/add/routes.py | 9 - app/routes/service/routes.py | 5 +- app/scripts/ansible/roles/backup.yml | 2 +- app/scripts/ansible/roles/letsencrypt.yml | 61 +-- .../roles/letsencrypt/tasks/delete.yml | 13 + .../roles/letsencrypt/tasks/install.yml | 125 ++++++ .../ansible/roles/letsencrypt/tasks/main.yml | 1 + .../roles/letsencrypt/templates/cloudflare.j2 | 2 + .../letsencrypt/templates/digitalocean.j2 | 2 + .../roles/letsencrypt/templates/linode.j2 | 3 + .../templates}/renew_letsencrypt.j2 | 2 +- .../roles/letsencrypt/templates/route53.j2 | 3 + .../ansible/roles/letsencrypt_standalone.yml | 12 + app/scripts/letsencrypt.sh | 44 -- app/static/js/add.js | 58 +-- app/static/js/le.js | 222 ++++++++++ app/static/js/metrics.js | 5 +- app/static/js/script.js | 48 +-- app/templates/add.html | 101 ++++- app/templates/ajax/ha/clusters.html | 8 +- app/templates/base.html | 2 +- app/templates/languages/en.html | 2 + app/templates/languages/fr.html | 2 + app/templates/languages/pt-br.html | 6 +- app/templates/languages/ru.html | 2 + app/views/ha/views.py | 189 +++++++-- app/views/service/lets_encrypt_views.py | 392 ++++++++++++++++++ requirements.txt | 1 + 40 files changed, 1221 insertions(+), 309 deletions(-) create mode 100644 app/modules/db/le.py create mode 100644 app/scripts/ansible/roles/letsencrypt/tasks/delete.yml create mode 100644 app/scripts/ansible/roles/letsencrypt/tasks/install.yml create mode 100644 app/scripts/ansible/roles/letsencrypt/tasks/main.yml create mode 100644 app/scripts/ansible/roles/letsencrypt/templates/cloudflare.j2 create mode 100644 app/scripts/ansible/roles/letsencrypt/templates/digitalocean.j2 create mode 100644 app/scripts/ansible/roles/letsencrypt/templates/linode.j2 rename app/scripts/ansible/roles/{ => letsencrypt/templates}/renew_letsencrypt.j2 (94%) create mode 100644 app/scripts/ansible/roles/letsencrypt/templates/route53.j2 create mode 100644 app/scripts/ansible/roles/letsencrypt_standalone.yml delete mode 100644 app/scripts/letsencrypt.sh create mode 100644 app/static/js/le.js create mode 100644 app/views/service/lets_encrypt_views.py diff --git a/app/api/routes/routes.py b/app/api/routes/routes.py index d570587b..950fb91b 100644 --- a/app/api/routes/routes.py +++ b/app/api/routes/routes.py @@ -12,6 +12,7 @@ from app.views.service.views import (ServiceView, ServiceActionView, ServiceBack ServiceConfigVersionsView, ServiceConfigList) from app.views.service.haproxy_section_views import ListenSectionView, UserListSectionView, PeersSectionView, \ GlobalSectionView, DefaultsSectionView +from app.views.service.lets_encrypt_views import LetsEncryptsView, LetsEncryptView from app.views.ha.views import HAView, HAVIPView, HAVIPsView from app.views.user.views import UserView, UserGroupView, UserRoles from app.views.udp.views import UDPListener, UDPListeners, UDPListenerActionView @@ -87,6 +88,8 @@ register_api(GitBackupView, 'backup_git', '/server/backup/git', 'backup_id') bp.add_url_rule('/server//ip', view_func=ServerIPView.as_view('server_ip_ip'), methods=['GET']) bp.add_url_rule('/server//ip', view_func=ServerIPView.as_view('server_ip'), methods=['GET']) register_api_for_not_api(CredView, 'cred', '/server/cred', 'cred_id') +register_api(LetsEncryptView, 'le_api', '/service/letsencrypt', 'le_id') +bp.add_url_rule('service/letsencrypts', view_func=LetsEncryptsView.as_view('les_api'), methods=['GET']) bp.add_url_rule('/server/creds', view_func=CredsView.as_view('creds'), methods=['GET']) bp.add_url_rule('/server/portscanner/', view_func=PortScannerView.as_view('port_scanner_ip'), methods=['GET', 'POST']) bp.add_url_rule('/server/portscanner/', view_func=PortScannerView.as_view('port_scanner'), methods=['GET', 'POST']) diff --git a/app/create_db.py b/app/create_db.py index 94cabd1b..0f806d9b 100644 --- a/app/create_db.py +++ b/app/create_db.py @@ -135,9 +135,10 @@ def default_values(): print(str(e)) data_source = [ - {'name': 'superAdmin', 'description': 'Has the highest level of administrative permissions and controls the actions of all other users'}, - {'name': 'admin', 'description': 'Has access everywhere except the Admin area'}, - {'name': 'user', 'description': 'Has the same rights as the admin but has no access to the Servers page'}, + {'name': 'superAdmin', + 'description': 'Has the highest level of administrative permissions and controls the actions of all other users'}, + {'name': 'admin', 'description': 'Has admin access to its groups'}, + {'name': 'user', 'description': 'Has the same rights as the admin but has no access to the Admin area'}, {'name': 'guest', 'description': 'Read-only access'} ] @@ -680,7 +681,7 @@ def update_db_v_8_1_0_3(): def update_ver(): try: - Version.update(version='8.1.0.1').execute() + Version.update(version='8.1.2').execute() except Exception: print('Cannot update version') diff --git a/app/modules/config/add.py b/app/modules/config/add.py index 9c702052..84ddd465 100644 --- a/app/modules/config/add.py +++ b/app/modules/config/add.py @@ -5,7 +5,6 @@ from flask import render_template import app.modules.db.sql as sql import app.modules.db.add as add_sql import app.modules.db.server as server_sql -import app.modules.server.ssh as ssh_mod import app.modules.common.common as common import app.modules.config.config as config_mod import app.modules.config.common as config_common @@ -326,45 +325,6 @@ def get_saved_servers(group: str, term: str) -> dict: return a -def get_le_cert(server_ip: str, lets_domain: str, lets_email: str) -> str: - proxy = sql.get_setting('proxy') - ssl_path = common.return_nice_path(sql.get_setting('cert_path'), is_service=0) - haproxy_dir = sql.get_setting('haproxy_dir') - script = "letsencrypt.sh" - proxy_serv = '' - ssh_settings = ssh_mod.return_ssh_keys_path(server_ip) - full_path = '/var/www/haproxy-wi/app' - - os.system(f"cp {full_path}/scripts/{script} {full_path}/{script}") - - if proxy is not None and proxy != '' and proxy != 'None': - proxy_serv = proxy - - commands = [ - f"chmod +x {full_path}/{script} && {full_path}/{script} PROXY={proxy_serv} haproxy_dir={haproxy_dir} DOMAIN={lets_domain} " - f"EMAIL={lets_email} SSH_PORT={ssh_settings['port']} SSL_PATH={ssl_path} HOST={server_ip} USER={ssh_settings['user']} " - f"PASS='{ssh_settings['password']}' KEY={ssh_settings['key']}" - ] - - output, error = server_mod.subprocess_execute(commands[0]) - - if error: - roxywi_common.logging('Roxy-WI server', error, roxywi=1) - return error - else: - for line in output: - if any(s in line for s in ("msg", "FAILED")): - try: - line = line.split(':')[1] - line = line.split('"')[1] - return line + "
" - except Exception: - return output - else: - os.remove(f'{full_path}/{script}') - return 'success: Certificate has been created' - - def get_ssl_cert(server_ip: str, cert_id: int) -> str: cert_path = sql.get_setting('cert_path') command = f"openssl x509 -in {cert_path}/{cert_id} -text" diff --git a/app/modules/db/cred.py b/app/modules/db/cred.py index 36542bc0..05ce66d3 100644 --- a/app/modules/db/cred.py +++ b/app/modules/db/cred.py @@ -73,9 +73,9 @@ def update_ssh_passphrase(cred_id: int, passphrase: str): out_error(e) -def get_ssh_by_id_and_group(creds_id: int, group_id: int) -> Cred: +def get_ssh_by_id_and_group(cred_id: int, group_id: int) -> Cred: try: - return Cred.select().where((Cred.group_id == group_id) & (Cred.id == creds_id)).execute() + return Cred.select().where((Cred.group_id == group_id) & (Cred.id == cred_id)).execute() except Cred.DoesNotExist: raise RoxywiResourceNotFound except Exception as e: diff --git a/app/modules/db/db_model.py b/app/modules/db/db_model.py index 07ebfa2b..d1f48326 100644 --- a/app/modules/db/db_model.py +++ b/app/modules/db/db_model.py @@ -786,6 +786,20 @@ class HaproxySection(BaseModel): constraints = [SQL('UNIQUE (server_id, type, name)')] +class LetsEncrypt(BaseModel): + id = AutoField + server_id = ForeignKeyField(Server, null=True, on_delete='SET NULL') + domains = CharField() + email = CharField() + api_key = CharField() + api_token = CharField() + type = CharField() + description = CharField() + + class Meta: + table_name = 'lets_encrypt' + + def create_tables(): conn = connect() with conn: @@ -796,5 +810,5 @@ def create_tables(): NginxMetrics, SystemInfo, Services, UserName, GitSetting, CheckerSetting, ApacheMetrics, WafNginx, ServiceStatus, KeepaliveRestart, PD, SmonHistory, SmonAgent, SmonTcpCheck, SmonHttpCheck, SmonPingCheck, SmonDnsCheck, S3Backup, SmonStatusPage, SmonStatusPageCheck, HaCluster, HaClusterSlave, HaClusterVip, HaClusterVirt, HaClusterService, - HaClusterRouter, MM, UDPBalancer, HaproxySection] + HaClusterRouter, MM, UDPBalancer, HaproxySection, LetsEncrypt] ) diff --git a/app/modules/db/ha_cluster.py b/app/modules/db/ha_cluster.py index 423067fb..a7656468 100644 --- a/app/modules/db/ha_cluster.py +++ b/app/modules/db/ha_cluster.py @@ -263,12 +263,14 @@ def delete_ha_virt(vip_id: int) -> None: pass -def check_ha_virt(vip_id: int) -> bool: +def check_ha_virt(vip_id: int) -> int: try: - HaClusterVirt.get(HaClusterVirt.vip_id == vip_id).virt_id - except Exception: - return False - return True + _ = HaClusterVirt.get(HaClusterVirt.vip_id == vip_id).virt_id + return 1 + except HaClusterVirt.DoesNotExist: + return 0 + except Exception as e: + out_error(e) def select_ha_virts(cluster_id: int) -> HaClusterVirt: diff --git a/app/modules/db/le.py b/app/modules/db/le.py new file mode 100644 index 00000000..d2ad74df --- /dev/null +++ b/app/modules/db/le.py @@ -0,0 +1,54 @@ +from app.modules.db.db_model import LetsEncrypt, Server +from app.modules.db.common import out_error +from app.modules.roxywi.exception import RoxywiResourceNotFound + + +def get_le(le_id: int) -> LetsEncrypt: + try: + return LetsEncrypt.get(LetsEncrypt.id == le_id) + except LetsEncrypt.DoesNotExist: + raise RoxywiResourceNotFound + except Exception as e: + out_error(e) + + +def get_le_with_group(le_id: int, group_id: int) -> LetsEncrypt: + try: + return LetsEncrypt.select().join(Server).where( + (LetsEncrypt.id == le_id) & + (Server.group_id == group_id) + ).get() + except LetsEncrypt.DoesNotExist: + raise RoxywiResourceNotFound + except Exception as e: + out_error(e) + + +def select_le_with_group(group_id: int) -> LetsEncrypt: + try: + return LetsEncrypt.select().join(Server).where(Server.group_id == group_id).execute() + except Exception as e: + out_error(e) + + +def insert_le(**kwargs) -> int: + try: + return LetsEncrypt.insert(**kwargs).execute() + except Exception as e: + out_error(e) + + +def update_le(le_id: int, **kwargs) -> int: + try: + return LetsEncrypt.update(**kwargs).where(LetsEncrypt.id == le_id).execute() + except LetsEncrypt.DoesNotExist: + raise RoxywiResourceNotFound + except Exception as e: + out_error(e) + + +def delete_le(le_id: int) -> None: + try: + LetsEncrypt.delete().where(LetsEncrypt.id == le_id).execute() + except Exception as e: + out_error(e) diff --git a/app/modules/roxywi/class_models.py b/app/modules/roxywi/class_models.py index 0039be64..b00c81bd 100644 --- a/app/modules/roxywi/class_models.py +++ b/app/modules/roxywi/class_models.py @@ -4,9 +4,10 @@ from typing import Optional, Annotated, Union, Literal, Any, Dict, List from shlex import quote from pydantic_core import CoreSchema, core_schema -from pydantic import BaseModel, Base64Str, StringConstraints, IPvAnyAddress, GetCoreSchemaHandler, AnyUrl +from pydantic import BaseModel, Base64Str, StringConstraints, IPvAnyAddress, GetCoreSchemaHandler, AnyUrl, root_validator, EmailStr DomainName = Annotated[str, StringConstraints(pattern=r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$")] +WildcardDomainName = Annotated[str, StringConstraints(pattern=r"^(?:[a-z0-9\*](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$")] class EscapedString(str): @@ -134,6 +135,7 @@ class ServerRequest(BaseModel): class GroupQuery(BaseModel): group_id: Optional[int] = None + recurse: Optional[bool] = False class GroupRequest(BaseModel): @@ -301,6 +303,52 @@ class SavedServerRequest(BaseModel): description: Optional[EscapedString] = None +class LetsEncryptRequest(BaseModel): + server_id: int + domains: List[WildcardDomainName] + email: Optional[EmailStr] = None + type: Literal['standalone', 'route53', 'cloudflare', 'digitalocean', 'linode'] + api_key: Optional[EscapedString] = None + api_token: EscapedString + description: Optional[EscapedString] = None + + @root_validator(pre=True) + @classmethod + def is_email_when_standalone(cls, values): + cert_type = '' + email = '' + if 'type' in values: + cert_type = values['type'] + if 'email' in values: + email = values['email'] + if cert_type == 'standalone' and email == '': + raise ValueError('Email must be when type is standalone') + return values + + @root_validator(pre=True) + @classmethod + def is_api_key_when_route53(cls, values): + cert_type = '' + api_key = '' + if 'type' in values: + cert_type = values['type'] + if 'api_key' in values: + api_key = values['api_key'] + if cert_type == 'route53' and api_key == '': + raise ValueError('api_key(secret key) must be when type is route53') + return values + + +class LetsEncryptDeleteRequest(BaseModel): + server_id: int + domains: List[WildcardDomainName] + email: Optional[str] = None + type: Literal['standalone', 'route53', 'cloudflare', 'digitalocean', 'linode'] + api_key: Optional[EscapedString] = None + api_token: EscapedString + description: Optional[EscapedString] = None + + class HaproxyBinds(BaseModel): ip: Optional[str] = None port: Annotated[int, Gt(1), Le(65535)] diff --git a/app/modules/server/server.py b/app/modules/server/server.py index c35e13d4..56461883 100644 --- a/app/modules/server/server.py +++ b/app/modules/server/server.py @@ -12,7 +12,6 @@ import app.modules.db.history as history_sql import app.modules.db.portscanner as ps_sql import app.modules.server.ssh as mod_ssh import app.modules.common.common as common -import app.modules.roxywi.auth as roxywi_auth import app.modules.roxywi.common as roxywi_common diff --git a/app/modules/server/ssh.py b/app/modules/server/ssh.py index 7719b86c..f90568de 100644 --- a/app/modules/server/ssh.py +++ b/app/modules/server/ssh.py @@ -51,8 +51,8 @@ def return_ssh_keys_path(server_ip: str, cred_id: int = None) -> dict: ssh_settings.setdefault('passphrase', passphrase) try: - ssh_port = [str(server[10]) for server in server_sql.select_servers(server=server_ip)] - ssh_settings.setdefault('port', ssh_port[0]) + server = server_sql.get_server_by_ip(server_ip) + ssh_settings.setdefault('port', server.port) except Exception as e: raise Exception(f'error: Cannot get SSH port: {e}') @@ -242,8 +242,6 @@ def get_creds(group_id: int = None, cred_id: int = None, not_shared: bool = Fals def _return_correct_ssh_file(cred: CredRequest) -> str: lib_path = get_config.get_config_var('main', 'lib_path') group_name = group_sql.get_group_name_by_id(cred.group_id) - # if not cred.key_enabled: - # return '' if group_name not in cred.name: return f'{lib_path}/keys/{cred.name}_{group_name}.pem' else: diff --git a/app/modules/service/ha_cluster.py b/app/modules/service/ha_cluster.py index ca76f8e5..6ec6056d 100644 --- a/app/modules/service/ha_cluster.py +++ b/app/modules/service/ha_cluster.py @@ -193,7 +193,7 @@ def insert_vip(cluster_id: int, cluster: HAClusterVIP, group_id: int) -> int: raise Exception(f'Cannot get servers: {e}') try: - vip_id = HaClusterVip.insert(cluster_id=cluster_id, router_id=router_id, vip=vip, return_master=cluster.return_master).execute() + vip_id = HaClusterVip.insert(cluster_id=cluster_id, router_id=router_id, vip=vip, use_src=cluster.use_src, return_master=cluster.return_master).execute() except Exception as e: raise Exception(f'error: Cannot save VIP {vip}: {e}') diff --git a/app/modules/service/installation.py b/app/modules/service/installation.py index 1a90caa3..8876c45d 100644 --- a/app/modules/service/installation.py +++ b/app/modules/service/installation.py @@ -230,7 +230,7 @@ def generate_service_inv(json_data: ServiceInstall, installed_service: str) -> o def run_ansible(inv: dict, server_ips: list, ansible_role: str) -> dict: inventory_path = '/var/www/haproxy-wi/app/scripts/ansible/inventory' - inventory = f'{inventory_path}/{ansible_role}.json' + inventory = f'{inventory_path}/{ansible_role}-{random.randint(0, 35)}.json' proxy = sql.get_setting('proxy') proxy_serv = '' tags = '' @@ -330,8 +330,13 @@ def run_ansible(inv: dict, server_ips: list, ansible_role: str) -> dict: def run_ansible_locally(inv: dict, ansible_role: str) -> dict: inventory_path = '/var/www/haproxy-wi/app/scripts/ansible/inventory' inventory = f'{inventory_path}/{ansible_role}-{random.randint(0, 35)}.json' - # proxy = sql.get_setting('proxy') - # proxy_serv = '' + proxy_serv = '' + proxy = sql.get_setting('proxy') + + if proxy is not None and proxy != '' and proxy != 'None': + proxy_serv = proxy + + inv['server']['hosts']['localhost']['PROXY'] = proxy_serv envvars = { 'ANSIBLE_DISPLAY_OK_HOSTS': 'no', diff --git a/app/routes/add/routes.py b/app/routes/add/routes.py index 7d5088bb..1c2d2263 100644 --- a/app/routes/add/routes.py +++ b/app/routes/add/routes.py @@ -284,15 +284,6 @@ def create_map(): return add_mod.edit_map(map_name, group) -@bp.post('lets') -def lets(): - server_ip = common.checkAjaxInput(request.form.get('serv')) - lets_domain = common.checkAjaxInput(request.form.get('lets_domain')) - lets_email = common.checkAjaxInput(request.form.get('lets_email')) - - return add_mod.get_le_cert(server_ip, lets_domain, lets_email) - - @bp.post('/nginx/upstream') @get_user_params() def add_nginx_upstream(): diff --git a/app/routes/service/routes.py b/app/routes/service/routes.py index b0f5b318..a0aed222 100644 --- a/app/routes/service/routes.py +++ b/app/routes/service/routes.py @@ -17,7 +17,7 @@ import app.modules.service.common as service_common import app.modules.roxywi.common as roxywi_common import app.modules.roxywi.overview as roxy_overview from app.views.service.views import ServiceActionView, ServiceBackendView, ServiceView - +from app.views.service.lets_encrypt_views import LetsEncryptView, LetsEncryptsView bp.add_url_rule('///', view_func=ServiceActionView.as_view('service_action_ip'), methods=['GET']) bp.add_url_rule('///', view_func=ServiceActionView.as_view('service_action'), methods=['GET']) @@ -25,6 +25,9 @@ bp.add_url_rule('///backend', view_func=ServiceBackendView.a bp.add_url_rule('///backend', view_func=ServiceBackendView.as_view('service_backend'), methods=['GET']) bp.add_url_rule('///status', view_func=ServiceView.as_view('service_ip'), methods=['GET']) bp.add_url_rule('///status', view_func=ServiceView.as_view('service'), methods=['GET']) +bp.add_url_rule('/letsencrypt', view_func=LetsEncryptView.as_view('le_web'), methods=['POST']) +bp.add_url_rule('/letsencrypt/', view_func=LetsEncryptView.as_view('le_web_id'), methods=['GET', 'PUT', 'DELETE']) +bp.add_url_rule('/letsencrypts', view_func=LetsEncryptsView.as_view('le_webs'), methods=['GET']) @bp.before_request diff --git a/app/scripts/ansible/roles/backup.yml b/app/scripts/ansible/roles/backup.yml index 968967b6..443b6ade 100644 --- a/app/scripts/ansible/roles/backup.yml +++ b/app/scripts/ansible/roles/backup.yml @@ -14,7 +14,7 @@ cron: name: "Roxy-WI Backup configs for server {{ SERVER }} {{ item }}" special_time: "{{ TIME }}" - job: "rsync -arv {{ TYPE }} /var/lib/roxy-wi/configs/{{ item }}/{{ SERVER }}* {{ USER }}@{{ HOST }}:{{ RPATH }}/roxy-wi-configs-backup/configs/{{ item }} -e 'ssh -i {{ KEY }} -o StrictHostKeyChecking=no' --log-file=/var/www/haproxy-wi/log/backup.log" + job: "rsync -arv {{ TYPE }} /var/lib/roxy-wi/configs/{{ item }}/{{ SERVER }}* {{ USER }}@{{ HOST }}:{{ RPATH }}/roxy-wi-configs-backup/configs/{{ item }} -e 'ssh -i {{ KEY }} -o StrictHostKeyChecking=no' --log-file=/var/www/roxy-wi/log/backup.log" when: not DELJOB delegate_to: localhost with_items: diff --git a/app/scripts/ansible/roles/letsencrypt.yml b/app/scripts/ansible/roles/letsencrypt.yml index 79b2a4a7..64e6ef46 100644 --- a/app/scripts/ansible/roles/letsencrypt.yml +++ b/app/scripts/ansible/roles/letsencrypt.yml @@ -1,56 +1,9 @@ -- hosts: "{{ variable_host }}" +--- +- name: Obtain Lets Encrypt certificate + hosts: localhost + connection: local become: yes become_method: sudo - tasks: - - - name: install EPEL Repository - yum: - name: epel-release - state: latest - when: (ansible_facts['os_family'] == "RedHat" or ansible_facts['os_family'] == 'CentOS') - ignore_errors: yes - failed_when: false - no_log: True - environment: - http_proxy: "{{PROXY}}" - https_proxy: "{{PROXY}}" - - - name: Install certbot - package: - name: certbot - state: present - environment: - http_proxy: "{{PROXY}}" - https_proxy: "{{PROXY}}" - - - name: Kill cerbot standalone - shell: ps ax |grep 'certbot certonly --standalone' |grep -v grep |awk '{print $1}' |xargs kill - ignore_errors: yes - failed_when: false - no_log: True - - - name: Get cert - command: certbot certonly --standalone -d "{{DOMAIN}}" --non-interactive --agree-tos --email "{{EMAIL}}" --http-01-port=8888 - - - name: Combine into pem file - shell: cat /etc/letsencrypt/live/{{DOMAIN}}/fullchain.pem /etc/letsencrypt/live/{{DOMAIN}}/privkey.pem > "{{SSL_PATH}}"/"{{DOMAIN}}".pem - - - name: Creates directory - file: - path: "{{haproxy_dir}}/scripts" - state: directory - - - name: Copy renew script - template: - src: /var/www/haproxy-wi/app/scripts/ansible/roles/renew_letsencrypt.j2 - dest: "{{haproxy_dir}}/scripts/renew_letsencrypt.sh" - mode: '0755' - ignore_errors: yes - failed_when: false - no_log: True - - - name: Creates cron jobs - cron: - name: "Let's encrypt renew script" - special_time: "monthly" - job: '{{haproxy_dir}}/scripts/renew_letsencrypt.sh' \ No newline at end of file + gather_facts: yes + roles: + - role: letsencrypt diff --git a/app/scripts/ansible/roles/letsencrypt/tasks/delete.yml b/app/scripts/ansible/roles/letsencrypt/tasks/delete.yml new file mode 100644 index 00000000..63c5d245 --- /dev/null +++ b/app/scripts/ansible/roles/letsencrypt/tasks/delete.yml @@ -0,0 +1,13 @@ +--- +- name: Delete RSYNC job + cron: + name: "Roxy-WI le certificate {{ main_domain }} {{ item.key }}" + special_time: monthly + state: absent + job: "rsync -arv /etc/letsencrypt/live/{{main_domain}}/* {{ item.value.split('@')[0] }}@{{ item.key }}:{{ ssl_path }} -e 'ssh -i {{ item.value.split('@')[1] }} -o StrictHostKeyChecking=no' --log-file=/var/www/roxy-wi/log/letsencrypt.log" + loop: "{{ servers | dict2items }}" + +- name: Delete DNS secret file + file: + path: "~/.secrets/certbot/{{ cert_type }}-{{ main_domain }}.ini" + state: absent diff --git a/app/scripts/ansible/roles/letsencrypt/tasks/install.yml b/app/scripts/ansible/roles/letsencrypt/tasks/install.yml new file mode 100644 index 00000000..11b4e2bc --- /dev/null +++ b/app/scripts/ansible/roles/letsencrypt/tasks/install.yml @@ -0,0 +1,125 @@ +--- +- name: Kill cerbot standalone + shell: ps ax |grep 'certbot certonly --standalone' |grep -v grep |awk '{print $1}' |xargs kill + ignore_errors: yes + failed_when: false + no_log: True + +- name: Creates certbot directory + file: + path: ~/.secrets/certbot/ + state: directory + +- name: Install Standalone + when: cert_type == "standalone" + block: + - name: install EPEL Repository + yum: + name: epel-release + state: latest + when: (ansible_facts['os_family'] == "RedHat" or ansible_facts['os_family'] == 'CentOS') + ignore_errors: yes + failed_when: false + no_log: True + environment: + http_proxy: "{{PROXY}}" + https_proxy: "{{PROXY}}" + + - name: Install certbot + package: + name: certbot + state: present + environment: + http_proxy: "{{PROXY}}" + https_proxy: "{{PROXY}}" + + - name: Get cert + command: certbot certonly --standalone "{{domains_command}}" --non-interactive --agree-tos --email "{{email}}" --http-01-port=8888 + + - name: Combine into pem file + shell: "cat /etc/letsencrypt/live/{{main_domain}}/fullchain.pem /etc/letsencrypt/live/{{main_domain}}/privkey.pem > {{ssl_path}}/{{main_domain}}.pem" + + - name: Creates directory + file: + path: "{{haproxy_dir}}/scripts" + state: directory + + - name: Copy renew script + template: + src: renew_letsencrypt.j2 + dest: "{{haproxy_dir}}/scripts/renew_letsencrypt.sh" + mode: '0755' + ignore_errors: yes + failed_when: false + no_log: True + + - name: Creates cron jobs + cron: + name: "Let's encrypt renew script" + special_time: "monthly" + job: '{{haproxy_dir}}/scripts/renew_letsencrypt.sh' + +- name: Install DNS cert + when: cert_type != "standalone" + block: + - name: install EPEL Repository + yum: + name: epel-release + state: latest + when: (ansible_facts['os_family'] == "RedHat" or ansible_facts['os_family'] == 'CentOS') + ignore_errors: yes + failed_when: false + no_log: True + environment: + http_proxy: "{{PROXY}}" + https_proxy: "{{PROXY}}" + + - name: Install certbot + package: + name: certbot + state: present + environment: + http_proxy: "{{PROXY}}" + https_proxy: "{{PROXY}}" + + - name: Install cert bot plugin + pip: + name: "certbot-dns-{{ cert_type }}" + executable: /usr/local/bin/pip3 + state: latest + + - name: Copy DNS secret file + template: + src: "{{ cert_type }}.j2" + dest: "~/.secrets/certbot/{{ cert_type }}-{{ main_domain }}.ini" + + - name: Obtain certificate + shell: "certbot certonly --dns-{{ cert_type }} {{domains_command}} --dns-{{ cert_type }}-credentials ~/.secrets/certbot/{{ cert_type }}-{{ main_domain }}.ini --dns-{{ cert_type }}-propagation-seconds 60" + environment: + AWS_CONFIG_FILE: "~/.secrets/certbot/{{ cert_type }}.ini" + +# - name: Obtain certificate +# shell: "touch /etc/letsencrypt/live/{{main_domain}}/fullchain.pem & touch /etc/letsencrypt/live/{{main_domain}}/privkey.pem" +# environment: +# AWS_CONFIG_FILE: "~/.secrets/certbot/{{ cert_type }}.ini" + + - name: Combine into pem file + shell: cat /etc/letsencrypt/live/{{main_domain}}/fullchain.pem /etc/letsencrypt/live/{{main_domain}}/privkey.pem > /etc/letsencrypt/live/{{main_domain}}/{{main_domain}}.pem + + - name: Copy certificate + shell: "scp -o StrictHostKeyChecking=no -i {{ item.value.split('@')[1] }} /etc/letsencrypt/live/{{main_domain}}/* {{ item.value.split('@')[0] }}@{{ item.key }}:{{ ssl_path }}" + loop: "{{ servers | dict2items }}" + + - name: Create certbot certificate renew job + cron: + name: "Roxy-WI certbot certificate renew" + minute: "0" + hour: "0,12" + job: "root /opt/certbot/bin/python -c 'import random; import time; time.sleep(random.random() * 3600)' && sudo certbot renew -q" + + - name: Create RSYNC job + cron: + name: "Roxy-WI le certificate {{ main_domain }} {{ item.key }}" + special_time: monthly + job: "rsync -arv /etc/letsencrypt/live/{{main_domain}}/* {{ item.value.split('@')[0] }}@{{ item.key }}:{{ ssl_path }} -e 'ssh -i {{ item.value.split('@')[1] }} -o StrictHostKeyChecking=no' --log-file=/var/www/roxy-wi/log/letsencrypt.log" + loop: "{{ servers | dict2items }}" diff --git a/app/scripts/ansible/roles/letsencrypt/tasks/main.yml b/app/scripts/ansible/roles/letsencrypt/tasks/main.yml new file mode 100644 index 00000000..855d2923 --- /dev/null +++ b/app/scripts/ansible/roles/letsencrypt/tasks/main.yml @@ -0,0 +1 @@ +- include_tasks: "{{ action }}.yml" diff --git a/app/scripts/ansible/roles/letsencrypt/templates/cloudflare.j2 b/app/scripts/ansible/roles/letsencrypt/templates/cloudflare.j2 new file mode 100644 index 00000000..a3db1fbe --- /dev/null +++ b/app/scripts/ansible/roles/letsencrypt/templates/cloudflare.j2 @@ -0,0 +1,2 @@ +# Cloudflare API token used by Certbot +dns_cloudflare_api_token = {{ token }} diff --git a/app/scripts/ansible/roles/letsencrypt/templates/digitalocean.j2 b/app/scripts/ansible/roles/letsencrypt/templates/digitalocean.j2 new file mode 100644 index 00000000..bca4f308 --- /dev/null +++ b/app/scripts/ansible/roles/letsencrypt/templates/digitalocean.j2 @@ -0,0 +1,2 @@ +# DigitalOcean API credentials used by Certbot +dns_digitalocean_token = {{ token }} diff --git a/app/scripts/ansible/roles/letsencrypt/templates/linode.j2 b/app/scripts/ansible/roles/letsencrypt/templates/linode.j2 new file mode 100644 index 00000000..d4d5ea09 --- /dev/null +++ b/app/scripts/ansible/roles/letsencrypt/templates/linode.j2 @@ -0,0 +1,3 @@ +# Linode API credentials used by Certbot +dns_linode_key = {{ token }} +dns_linode_version = 4 diff --git a/app/scripts/ansible/roles/renew_letsencrypt.j2 b/app/scripts/ansible/roles/letsencrypt/templates/renew_letsencrypt.j2 similarity index 94% rename from app/scripts/ansible/roles/renew_letsencrypt.j2 rename to app/scripts/ansible/roles/letsencrypt/templates/renew_letsencrypt.j2 index ee7e3de5..5c6d9f91 100644 --- a/app/scripts/ansible/roles/renew_letsencrypt.j2 +++ b/app/scripts/ansible/roles/letsencrypt/templates/renew_letsencrypt.j2 @@ -20,4 +20,4 @@ for i in $(ls -d */ |awk -F"/" '{print $1}'); do done # Reload HAProxy -sudo systemctl reload haproxy \ No newline at end of file +sudo systemctl reload haproxy diff --git a/app/scripts/ansible/roles/letsencrypt/templates/route53.j2 b/app/scripts/ansible/roles/letsencrypt/templates/route53.j2 new file mode 100644 index 00000000..6a358c7c --- /dev/null +++ b/app/scripts/ansible/roles/letsencrypt/templates/route53.j2 @@ -0,0 +1,3 @@ +[default] +aws_access_key_id={{ secret_key }} +aws_secret_access_key={{ token }} diff --git a/app/scripts/ansible/roles/letsencrypt_standalone.yml b/app/scripts/ansible/roles/letsencrypt_standalone.yml new file mode 100644 index 00000000..2799453d --- /dev/null +++ b/app/scripts/ansible/roles/letsencrypt_standalone.yml @@ -0,0 +1,12 @@ +--- +- name: Obtain Lets Encrypt certificate + hosts: all + connection: local + become: yes + become_method: sudo + gather_facts: yes + roles: + - role: letsencrypt + environment: + http_proxy: "{{PROXY}}" + https_proxy: "{{PROXY}}" diff --git a/app/scripts/letsencrypt.sh b/app/scripts/letsencrypt.sh deleted file mode 100644 index cc6c11d4..00000000 --- a/app/scripts/letsencrypt.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -for ARGUMENT in "$@" -do - KEY=$(echo $ARGUMENT | cut -f1 -d=) - VALUE=$(echo $ARGUMENT | cut -f2 -d=) - - case "$KEY" in - PROXY) PROXY=${VALUE} ;; - HOST) HOST=${VALUE} ;; - USER) USER=${VALUE} ;; - PASS) PASS=${VALUE} ;; - KEY) KEY=${VALUE} ;; - SSH_PORT) SSH_PORT=${VALUE} ;; - DOMAIN) DOMAIN=${VALUE} ;; - EMAIL) EMAIL=${VALUE} ;; - SSL_PATH) SSL_PATH=${VALUE} ;; - haproxy_dir) haproxy_dir=${VALUE} ;; - *) - esac -done - -export ANSIBLE_HOST_KEY_CHECKING=False -export ANSIBLE_DISPLAY_SKIPPED_HOSTS=False -export ACTION_WARNINGS=False -export LOCALHOST_WARNING=False -export COMMAND_WARNINGS=False - -PWD=/var/www/haproxy-wi/app/scripts/ansible/ -echo "$HOST ansible_port=$SSH_PORT" > $PWD/$HOST - -if [[ $KEY == "" ]]; then - ansible-playbook $PWD/roles/letsencrypt.yml -e "ansible_user=$USER ansible_ssh_pass=$PASS ansible_port=$SSH_PORT variable_host=$HOST PROXY=$PROXY DOMAIN=$DOMAIN EMAIL=$EMAIL haproxy_dir=$haproxy_dir SSL_PATH=$SSL_PATH" -i $PWD/$HOST -else - ansible-playbook $PWD/roles/letsencrypt.yml --key-file $KEY -e "ansible_user=$USER ansible_port=$SSH_PORT variable_host=$HOST PROXY=$PROXY DOMAIN=$DOMAIN EMAIL=$EMAIL haproxy_dir=$haproxy_dir SSL_PATH=$SSL_PATH" -i $PWD/$HOST -fi - -if [ $? -gt 0 ] -then - echo "error: Can't create SSL certificate" - exit 1 -else - echo "ok" -fi -rm -f $PWD/$HOST diff --git a/app/static/js/add.js b/app/static/js/add.js index 78aed3d0..ca4ec044 100644 --- a/app/static/js/add.js +++ b/app/static/js/add.js @@ -1,6 +1,25 @@ +window.onload = function() { + var cur_url = window.location.href.split('/').pop(); + let activeTabIdx = $('#tabs').tabs('option','active'); + if (cur_url.split('#')[1] === 'ssl') { + if (activeTabIdx === 4) { + getLes(); + } + } +} $( function() { + $("#tabs ul li").click(function () { + let activeTab = $(this).find("a").attr("href"); + let activeTabClass = activeTab.replace('#', ''); + $('.menu li ul li').each(function () { + activeSubMenu($(this), activeTabClass) + }); + if (activeTab === '#ssl') { + getLes(); + } + }); $("#listen-mode-select").on('selectmenuchange', function () { - if ($("#listen-mode-select option:selected").val() == "tcp") { + if ($("#listen-mode-select option:selected").val() === "tcp") { $("#https-listen-span").hide("fast"); $("#https-hide-listen").hide("fast"); $("#compression").checkboxradio("disable"); @@ -19,7 +38,7 @@ $( function() { } }); $("#frontend-mode-select").on('selectmenuchange', function () { - if ($("#frontend-mode-select option:selected").val() == "tcp") { + if ($("#frontend-mode-select option:selected").val() === "tcp") { $("#https-frontend-span").hide("fast"); $("#https-hide-frontend").hide("fast"); $("#compression2").checkboxradio("disable"); @@ -442,6 +461,7 @@ $( function() { $(this).children("#add3").css('border-left', '4px solid #5D9CEB'); $(this).children("#add3").css('background-color', 'var(--right-menu-blue-rolor)'); }); + getLes(); $("#tabs").tabs("option", "active", 4); }); $("#add4").on("click", function () { @@ -530,40 +550,6 @@ $( function() { } }); }); - $('#lets_button').click(function () { - let lets_domain = $('#lets_domain').val(); - let lets_email = $('#lets_email').val(); - if (lets_email == '' || lets_domain == '') { - toastr.error('Fields cannot be empty'); - } else if (validateEmail(lets_email)) { - $("#ajax-ssl").html(wait_mess); - $.ajax({ - url: "/add/lets", - data: { - serv: $('#serv_for_lets').val(), - lets_domain: lets_domain, - lets_email: lets_email - }, - type: "POST", - success: function (data) { - if (data.indexOf('error:') != '-1' || data.indexOf('ERROR') != '-1' || data.indexOf('FAILED') != '-1') { - toastr.clear(); - toastr.error(data); - } else if (data.indexOf('WARNING') != '-1') { - toastr.clear(); - toastr.warning(data); - } else { - toastr.clear(); - toastr.success(data); - } - $("#ajax-ssl").html(''); - } - }); - } else { - toastr.clear(); - toastr.error('Wrong e-mail format'); - } - }); $('[name=add-server-input]').click(function () { $("[name=add_servers]").append(add_server_var); changePortCheckFromServerPort(); diff --git a/app/static/js/le.js b/app/static/js/le.js new file mode 100644 index 00000000..91d557e5 --- /dev/null +++ b/app/static/js/le.js @@ -0,0 +1,222 @@ +let provides = {'standalone': "Stand alone", 'route53': 'Route53', 'linode': 'Linode', 'cloudflare': 'Cloudflare', 'digitalocean': 'Digitalocean'}; +$( function() { + let typeSelect = $( "#new-le-type" ); + typeSelect.on('selectmenuchange',function() { + if (typeSelect.val() === 'standalone') { + $('.le-standalone').show(); + $('.le-dns').hide(); + $('.le-aws').hide(); + } else if (typeSelect.val() === 'cloudflare' || typeSelect.val() === 'digitalocean' || typeSelect.val() === 'linode') { + $('.le-standalone').hide(); + $('.le-dns').show(); + $('.le-aws').hide(); + } else if (typeSelect.val() === 'route53' ) { + $('.le-standalone').hide(); + $('.le-dns').hide(); + $('.le-aws').show(); + } + }); +}); +function addLe(dialogId) { + let domain = $('#new-le-domain').val(); + let email = $('#new-le-email').val(); + let type = $('#new-le-type').val(); + let api_key = ''; + let api_token = $('#new-le-token').val(); + let valid = true; + let allFields = ''; + if (type === 'standalone') { + allFields = $([]).add($('#new-le-domain')).add($('#new-le-email')); + allFields.removeClass("ui-state-error"); + valid = valid && checkLength($('#new-le-email'), "Email", 1); + } + if (type === 'cloudflare' || type === 'digitalocean' || type === 'linode') { + allFields = $([]).add($('#new-le-domain')).add($('#new-le-token')); + allFields.removeClass("ui-state-error"); + valid = valid && checkLength($('#new-le-token'), "Token", 1); + } + if (type === 'route53') { + allFields = $([]).add($('#new-le-domain')).add($('#new-le-access_key_id')).add($('#new-le-secret_access_key')); + allFields.removeClass("ui-state-error"); + valid = valid && checkLength($('#new-le-access_key_id'), "Access key ID", 1); + valid = valid && checkLength($('#new-le-secret_access_key'), "Access key", 1); + } + valid = valid && checkLength($('#new-le-domain'), "Domains", 1); + if ($('#new-le-server_id').val() === '------' || $('#new-le-server_id').val() === null) { + toastr.warning('Select server firts') + return false; + } + if (!valid) { + return false; + } + if (type === 'standalone') { + if (!validateEmail(email)) { + toastr.warning('Invalid email format'); + return false; + } + } + if (type === 'route53') { + api_key = $('#new-le-access_key_id').val(); + api_token = $('#new-le-secret_access_key').val(); + } + let domains = []; + if (domain.includes(',')) { + domains = domain.split(',').filter(function (item) { + return item.trim() !== ''; + }); + } else if (domain.includes(' ')) { + domains = domain.split(' ').filter(function (item) { + return item.trim() !== ''; + }); + } else { + domains.push(domain); + } + let jsonData = { + 'server_id': $('#new-le-server_id').val(), + 'domains': domains, + 'email': email, + 'type': type, + 'api_key': api_key, + 'api_token': api_token, + 'description': $('#new-le-description').val(), + } + $.ajax({ + url: '/service/letsencrypt', + method: 'POST', + data: JSON.stringify(jsonData), + contentType: "application/json; charset=utf-8", + success: function (data) { + if (data.status === 'failed') { + toastr.error(data); + } else { + getLe(data['id'], dialogId); + } + }, + }); +} +function removeLe(leId) { + $("#lets-" + leId).css("background-color", "#f2dede"); + $.ajax({ + url: '/service/letsencrypt/' + leId, + method: 'DELETE', + contentType: "application/json; charset=utf-8", + statusCode: { + 204: function (xhr) { + $("#lets-" + leId).remove(); + }, + 404: function (xhr) { + $("#lets-" + leId).remove(); + } + }, + success: function (data) { + if (data) { + if (data.status === "failed") { + toastr.error(data); + } + } + }, + }); +} +function confirmDeleteLe(id) { + $( "#dialog-confirm" ).dialog({ + resizable: false, + height: "auto", + width: 400, + modal: true, + title: delete_word + " Let's encrypt?", + buttons: [{ + text: delete_word, + click: function () { + $(this).dialog("close"); + removeLe(id); + } + },{ + text: cancel_word, + click: function () { + $(this).dialog("close"); + } + }] + }); +} +function openLeDialog() { + $("#le-add-table").dialog({ + autoOpen: true, + resizable: false, + height: "auto", + width: 500, + modal: true, + title: $('#translate').attr('data-create') + " Let's encrypt", + show: { + effect: "fade", + duration: 200 + }, + hide: { + effect: "fade", + duration: 200 + }, + buttons: [ + { + text: $('#translate').attr('data-create'), + click: function () { + addLe($(this)); + } + }, { + text: cancel_word, + click: function () { + $(this).dialog("close"); + } + } + ] + }); +} +function getLe(leId, dialogId) { + $.ajax({ + url: '/service/letsencrypt/' + leId + "?recurse=True", + contentType: "application/json; charset=utf-8", + success: function (data) { + if (data.status === 'failed') { + toastr.error(data); + } else { + showLe(data); + $.getScript(awesome); + $(dialogId).dialog("close"); + } + }, + }); +} +function getLes() { + $.ajax({ + url: '/service/letsencrypts?recurse=True', + contentType: "application/json; charset=utf-8", + success: function (data) { + if (data.status === 'failed') { + toastr.error(data); + } else { + $('#le_table_body').empty(); + for (let k in data) { + showLe(data[k]); + } + $.getScript(awesome); + } + }, + }); +} +function showLe(data) { + let list_domains = ''; + for (let d of eval(data['domains'])) { + list_domains += d + ' '; + if (d < data['domains'].length - 1) { + list_domains += ', '; + } + } + let le_tag = elem("tr", {"id":"lets-" + data['id']}, [ + elem("td", {"class":"padding10 first-collumn"}, data['server_id']['hostname']), + elem("td", {"style": "width: 10%;"}, provides[data['type']]), + elem("td", {"style": "width: 30%;"}, list_domains), + elem("td", {"style": "width: 38%;"}, data['description']), + elem("td", null, [ + elem("a", {"class":"delete","onclick":"confirmDeleteLe("+data['id']+")","title":"Delete","style":"cursor: pointer; width: 5%;"}), + ]) + ]) + $('#le_table_body').append(le_tag); +} diff --git a/app/static/js/metrics.js b/app/static/js/metrics.js index 610afd4a..68e28948 100644 --- a/app/static/js/metrics.js +++ b/app/static/js/metrics.js @@ -353,7 +353,10 @@ function renderServiceChart(data, labels, server, service) { config.options.plugins.title.text = data[1] + ' ' + additional_title; let myChart = new Chart(ctx, config); myChart.update(); - stream_chart(myChart, service, server); + charts.push(myChart) + if (service !== 'waf') { + stream_chart(myChart, service, server); + } } function getNginxChartData(server) { $.ajax({ diff --git a/app/static/js/script.js b/app/static/js/script.js index 2d9930d8..ada2bbd7 100644 --- a/app/static/js/script.js +++ b/app/static/js/script.js @@ -41,40 +41,13 @@ function show_current_page(id) { $( function() { $('.menu li ul li').each(function () { let link = $(this).find('a').attr('href'); - let link2 = link.split('#')[1]; - let link3 = link.split('/')[2]; - let link4 = link.split('/')[3]; - // if (cur_url[1] == null) { - // cur_url[1] = 'haproxy'; - // } let full_uri = window.location.pathname let full_uri1 = window.location.hash - let full_uri2 = cur_url[0] + '/' + cur_url[1] + '/' + cur_url[2] - let full_uri3 = link2 + '/' + link3 + '/' + link4 - // console.log(link) - // console.log(window.location.hash) let params = new URL(document.location.toString()).searchParams; - // console.log(params.get("service")) - // console.log(link) - // console.log(full_uri) - // console.log(full_uri1) - // console.log(full_uri + "/" + full_uri1) - // if (full_uri1 === '/service/haproxy') { - // console.log(full_uri) - // console.log(full_uri1) - // } if (full_uri === link) { show_current_page($(this)) - // } else if (window.location.pathname.indexOf('add/haproxy#') != '-1' && link.indexOf('add/haproxy#proxy') != '-1') { - // show_current_page($(this)) } else if (link === full_uri + full_uri1) { show_current_page($(this)) - // } else if (link === '/admin#servers' && full_uri1 === '#servers') { - // show_current_page($(this)) - // } else if (link === '/admin#ssh' && full_uri1 === '#ssh') { - // show_current_page($(this)) - // } else if (link === '/add/haproxy#proxy' && full_uri1 === '#proxy') { - // show_current_page($(this)) } else if (link === '/add/haproxy#ssl' && full_uri1 === '#ssl' && params.get("service") != 'nginx') { show_current_page($(this)) } else if (link === '/add/haproxy#ssl' && full_uri1 === '#ssl' && params.get("service") === 'nginx') { @@ -1007,15 +980,20 @@ function changePassword() { effect: "fade", duration: 200 }, - buttons: { - "Change": function () { - changeUserPasswordItOwn($(this)); - }, - Cancel: function () { - $(this).dialog("close"); - $('#missmatchpass').hide(); + buttons: [ + { + text: $('#translate').attr('data-change'), + click: function () { + changeUserPasswordItOwn($(this)); + } + }, { + text: cancel_word, + click: function () { + $(this).dialog("close"); + $('#missmatchpass').hide(); + } } - } + ] }); } function changeUserPasswordItOwn(d) { diff --git a/app/templates/add.html b/app/templates/add.html index 5c708f1c..3157f5e4 100644 --- a/app/templates/add.html +++ b/app/templates/add.html @@ -19,6 +19,7 @@ +
  • {{lang.words.create|title()}} {{lang.words.proxy}}
  • @@ -92,29 +93,19 @@ - +
    - - + + + + - - - - - - +

    Let's Encrypt

    {{lang.words.server|title()}}{{lang.words.domain|title()}}{{lang.words.email|title()}}{{lang.words.type|title()}}{{lang.words.domains|title()}}{{lang.words.desc|title()}}
    - {{ select('serv_for_lets', values=g.user_params['servers'], is_servers='true') }} - - {{ input('lets_domain', placeholder="example.com") }} - - {{ input('lets_email') }} - - -
    +
    + {{lang.words.create|title()}}
@@ -366,4 +357,80 @@ for (var i = 0; i <= serv_ports.length; i++) { 'This can be done easily in HAProxy by adding the keyword backup on the server line. If multiple backup servers are configured, only the first active one is used.">backup'); } + {% endblock %} diff --git a/app/templates/ajax/ha/clusters.html b/app/templates/ajax/ha/clusters.html index 43b0024a..feee9449 100644 --- a/app/templates/ajax/ha/clusters.html +++ b/app/templates/ajax/ha/clusters.html @@ -4,16 +4,16 @@
@@ -47,7 +47,7 @@ {%- for vip in vips %} {% if g.user_params['role'] <= 2 %} - {{vip.vip}} + {{vip.vip}} {% else %} {{vip.vip}} {%- endif -%} diff --git a/app/templates/base.html b/app/templates/base.html index 1c92e62e..a9b09432 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -20,7 +20,7 @@ data-installing="{{lang.words.installing|title()}}" data-creating="{{lang.words.creating|title()}}" data-roxywi_timeout="{{lang.ha_page.roxywi_timeout}}" data-check_apache_log="{{lang.ha_page.check_apache_log}}" data-was_installed="{{lang.ha_page.was_installed}}" data-start_enter="{{lang.ha_page.start_enter}}" data-apply="{{lang.words.apply|title()}}" data-reconfigure="{{lang.words.reconfigure|title()}}" data-server="{{lang.words.server|title()}}" data-port="{{lang.words.port}}" - data-weight="{{lang.words.weight}}" data-uptime="{{lang.words.uptime}}" data-downtime="{{lang.words.downtime}}" /> + data-weight="{{lang.words.weight}}" data-uptime="{{lang.words.uptime}}" data-downtime="{{lang.words.downtime}}" data-create="{{lang.words.create|title()}}" /> {% include 'include/main_head.html' %} diff --git a/app/templates/languages/en.html b/app/templates/languages/en.html index 48ec86fc..14c9ca43 100644 --- a/app/templates/languages/en.html +++ b/app/templates/languages/en.html @@ -404,6 +404,7 @@ "option_temp": "Create, edit and delete options with given parameters. And after use them as autocomplete in the 'Add' sections", "server_temp": "Create, edit and delete servers. And after use them as autocomplete in the 'Add' sections", "use_add": "And use it in the 'Add' sections", + "comma_separated": "You can specify several, separated by a comma or a space", }, "buttons": { "disable_ssl_check": "Disable SSL check", @@ -870,6 +871,7 @@ "rule": "rule", "existing": "existing", "domain": "domain", + "domains": "domains", "all": "all", "just": "just", "without": "without", diff --git a/app/templates/languages/fr.html b/app/templates/languages/fr.html index 07bb7fb9..6f922796 100644 --- a/app/templates/languages/fr.html +++ b/app/templates/languages/fr.html @@ -404,6 +404,7 @@ "option_temp": "Créer, modifier et supprimer des options avec des paramètres donnés. Et ensuite les utiliser comme autocomplétion dans les sections 'Ajouter'", "server_temp": "Créer, modifier et supprimer des serveurs. Et par la suite, utilisez-les en tant que liste déroulante dans les sections 'Ajouter'", "use_add": "Et utilisez-le dans les sections 'Ajouter'.", + "comma_separated": "Vous pouvez en spécifier plusieurs, séparés par une virgule ou un espace", }, "buttons": { "disable_ssl_check": "Désactiver la vérification SSL", @@ -870,6 +871,7 @@ "rule": "règle", "existing": "existant", "domain": "domaine", + "domains": "domaines", "all": "tout", "just": "juste", "without": "sans", diff --git a/app/templates/languages/pt-br.html b/app/templates/languages/pt-br.html index e1f9cfd6..ba2d8598 100644 --- a/app/templates/languages/pt-br.html +++ b/app/templates/languages/pt-br.html @@ -404,6 +404,7 @@ "option_temp": "Crie, edite e exclua opções com determinados parâmetros. Usá-los como preenchimento automático nas seções 'Adicionar'", "server_temp": "Crie, edite e exclua servidores com determinados parâmetros. Usá-los como preenchimento automático nas seções 'Adicionar'", "use_add": "Usa isso as seções 'Adicionar'", + "comma_separated": "Você pode especificar vários, separados por uma vírgula ou um espaço", }, "buttons": { "disable_ssl_check": "Ativar a verificação de SSL", @@ -868,8 +869,9 @@ "display": "exibir", "default_backend": "Default backend", "rule": "regra", - "existing": "existing", - "domain": "domain", + "existing": "existente", + "domain": "domínio", + "domains": "domínios", "all": "todos", "just": "somente", "without": "sem", diff --git a/app/templates/languages/ru.html b/app/templates/languages/ru.html index 6cdd6b4c..ca3de39b 100644 --- a/app/templates/languages/ru.html +++ b/app/templates/languages/ru.html @@ -404,6 +404,7 @@ "option_temp": "Создавать, редактировать и удалять опции с заданными параметрами. И после использовать их как автозаполнение в разделах «Добавить прокси»", "server_temp": "Создание, редактирование и удаление серверов. И после использовать их как автозаполнение в разделах «Добавить прокси»", "use_add": "И использовать их в разделах «Добавить прокси»", + "comma_separated": "Можно указать несколько, разделенных запятой, либо пробелом", }, "buttons": { "disable_ssl_check": "Отключить проверку SSL", @@ -870,6 +871,7 @@ "rule": "правило", "existing": "существующие", "domain": "домен", + "domains": "домены", "all": "все", "just": "только", "without": "без", diff --git a/app/views/ha/views.py b/app/views/ha/views.py index df2ed5c3..6d79944b 100644 --- a/app/views/ha/views.py +++ b/app/views/ha/views.py @@ -5,6 +5,7 @@ from flask import render_template, request, jsonify, g from playhouse.shortcuts import model_to_dict import app.modules.db.ha_cluster as ha_sql +import app.modules.db.service as service_sql import app.modules.roxywi.common as roxywi_common import app.modules.common.common as common import app.modules.service.ha_cluster as ha_cluster @@ -39,36 +40,87 @@ class HAView(MethodView): type: 'integer' responses: 200: - description: Successful operation + description: HA details retrieved successfully schema: - type: 'object' + type: object properties: description: - type: 'string' + type: string + description: Description of the HA eth: - type: 'string' + type: string + description: Ethernet interface group_id: - type: 'integer' - haproxy: - type: 'integer' + type: integer + description: Group ID id: - type: 'integer' + type: integer + description: ID of the HA name: - type: 'string' - nginx: - type: 'integer' + type: string + description: Name of the listener pos: - type: 'integer' + type: integer + description: Position + return_master: + type: integer + description: Return master flag + servers: + type: array + items: + type: object + properties: + eth: + type: string + description: Ethernet interface + id: + type: integer + description: Server ID + master: + type: integer + description: Master flag + services: + type: object + properties: + apache: + type: object + properties: + docker: + type: integer + description: Docker flag for Apache + enabled: + type: integer + description: Enabled flag for Apache + haproxy: + type: object + properties: + docker: + type: integer + description: Docker flag for HAProxy + enabled: + type: integer + description: Enabled flag for HAProxy + nginx: + type: object + properties: + docker: + type: integer + description: Docker flag for NGINX + enabled: + type: integer + description: Enabled flag for NGINX syn_flood: - type: 'integer' + type: integer + description: SYN Flood protection flag use_src: - type: 'integer' + type: integer + description: Use source flag vip: - type: 'string' + type: string + description: Virtual IP address virt_server: - type: 'boolean' - default: - description: Unexpected error + type: integer + description: Virtual server flag """ if not cluster_id: if request.method == 'GET': @@ -88,25 +140,48 @@ class HAView(MethodView): cluster_services = ha_sql.select_cluster_services(cluster_id) vip = ha_sql.select_cluster_vip(cluster_id, router_id) is_virt = ha_sql.check_ha_virt(vip.id) + if vip.use_src: + use_src = 1 + else: + use_src = 0 for cluster in clusters: settings = model_to_dict(cluster) settings.setdefault('vip', vip.vip) settings.setdefault('virt_server', is_virt) - settings.setdefault('use_src', vip.use_src) + settings.setdefault('use_src', use_src) settings.setdefault('return_master', vip.return_master) + settings['servers'] = [] + settings['services'] = { + 'haproxy': { + 'enabled': 0, + 'docker': 0 + }, + 'nginx': { + 'enabled': 0, + 'docker': 0 + }, + 'apache': { + 'enabled': 0, + 'docker': 0 + } + } for slave in slaves: + server_id = slave[0] if slave[31]: settings.setdefault('eth', slave[32]) + server_settings = {'id': server_id, 'eth': slave[32], 'master': slave[31]} + settings['servers'].append(server_settings) for c_s in cluster_services: + is_dockerized = int(service_sql.select_service_setting(server_id, c_s.service_id, 'dockerized')) if int(c_s.service_id) == 1: - settings.setdefault('haproxy', 1) - elif int(c_s.service_id) == 2: - settings.setdefault('nginx', 1) - elif int(c_s.service_id) == 4: - settings.setdefault('apache', 1) + settings['services']['haproxy'] = {'enabled': 1, 'docker': is_dockerized} + if int(c_s.service_id) == 2: + settings['services']['nginx'] = {'enabled': 1, 'docker': is_dockerized} + if int(c_s.service_id) == 4: + settings['services']['apache'] = {'enabled': 1, 'docker': is_dockerized} return jsonify(settings) @@ -353,42 +428,74 @@ class HAVIPView(MethodView): description: 'Can be only "cluster"' required: true type: 'string' - - in: 'path' - name: 'cluster_id' - description: 'ID of the HA cluster to retrieve' + - name: cluster_id + in: path + type: integer required: true - type: 'integer' - - in: 'path' - name: 'vip_id' - description: 'ID of the VIP to retrieve' + description: ID of the cluster + - name: vip_id + in: path + type: integer required: true - type: 'integer' + description: ID of the VIP responses: 200: - description: Successful operation + description: HAVIP details retrieved successfully schema: type: object properties: cluster_id: - type: 'integer' + type: integer + description: ID of the cluster + eth: + type: string + description: Ethernet interface id: - type: 'integer' + type: integer + description: ID of the HAVIP return_master: - type: 'integer' + type: integer + description: Return master flag router_id: - type: 'integer' + type: integer + description: ID of the router + servers: + type: array + items: + type: object + properties: + eth: + type: string + description: Ethernet interface + id: + type: integer + description: Server ID + master: + type: integer + description: Master flag use_src: - type: 'integer' + type: integer + description: Use source flag vip: - type: 'string' - default: - description: Unexpected error + type: string + description: Virtual IP address + virt_server: + type: integer + description: Virtual server flag """ try: vip = ha_sql.select_cluster_vip_by_vip_id(cluster_id, vip_id) + slaves = ha_sql.select_cluster_slaves(cluster_id, vip.router_id) settings = model_to_dict(vip, recurse=False) is_virt = ha_sql.check_ha_virt(vip.id) settings.setdefault('virt_server', is_virt) + settings['servers'] = [] + for slave in slaves: + server_id = slave[0] + if slave[31]: + settings.setdefault('eth', slave[32]) + server_settings = {'id': server_id, 'eth': slave[32], 'master': slave[31]} + settings['servers'].append(server_settings) return jsonify(settings) except Exception as e: return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot get VIP') diff --git a/app/views/service/lets_encrypt_views.py b/app/views/service/lets_encrypt_views.py new file mode 100644 index 00000000..37dfafa8 --- /dev/null +++ b/app/views/service/lets_encrypt_views.py @@ -0,0 +1,392 @@ +import ast +from typing import Union + +from flask.views import MethodView +from flask_pydantic import validate +from flask import jsonify +from flask_jwt_extended import jwt_required +from playhouse.shortcuts import model_to_dict + +import app.modules.db.sql as sql +import app.modules.db.le as le_sql +import app.modules.db.server as server_sql +import app.modules.common.common as common +import app.modules.roxywi.common as roxywi_common +import app.modules.service.installation as service_mod +from app.modules.db.db_model import LetsEncrypt +from app.modules.server.ssh import return_ssh_keys_path +from app.middleware import get_user_params, page_for_admin, check_group +from app.modules.roxywi.class_models import LetsEncryptRequest, LetsEncryptDeleteRequest, IdResponse, GroupQuery, BaseResponse +from app.modules.common.common_classes import SupportClass + + +class LetsEncryptView(MethodView): + methods = ['GET', 'POST', 'PUT', 'DELETE'] + decorators = [jwt_required(), get_user_params(), page_for_admin(level=3), check_group()] + + @validate(query=GroupQuery) + def get(self, le_id: int, query: GroupQuery): + """ + Get Let's Encrypt details. + --- + tags: + - Let's Encrypt + parameters: + - name: le_id + in: path + type: integer + required: true + description: ID of the Let's Encrypt configuration + - name: group_id + in: query + type: integer + required: false + description: ID of the group (only for role superAdmin) + responses: + 200: + description: Let's Encrypt details retrieved successfully + schema: + type: object + properties: + api_key: + type: string + description: API key + api_token: + type: string + description: API token + description: + type: string + description: Description of the Let's Encrypt configuration + domains: + type: array + description: List of domains associated with the Let's Encrypt configuration + email: + type: string + description: Email associated with the Let's Encrypt account + id: + type: integer + description: ID of the Let's Encrypt configuration + server_id: + type: integer + description: ID of the server + type: + type: string + description: Type of the Let's Encrypt configuration + enum: ['standalone', 'route53', 'cloudflare', 'digitalocean', 'linode'] + """ + group_id = SupportClass.return_group_id(query) + try: + le = le_sql.get_le_with_group(le_id, group_id) + le_dict = model_to_dict(le, recurse=False) + le_dict['domains'] = ast.literal_eval(le_dict['domains']) + return jsonify(le_dict) + except Exception as e: + return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot get Let\'s Encrypt') + + @validate(body=LetsEncryptRequest) + def post(self, body: LetsEncryptRequest): + """ + Create a Let's Encrypt configuration. + --- + tags: + - Let's Encrypt + parameters: + - name: group_id + in: query + type: integer + required: false + description: ID of the group (only for role superAdmin) + - name: body + in: body + required: true + schema: + type: object + properties: + api_key: + type: string + description: API key + api_token: + type: string + description: API token + description: + type: string + description: Description of the Let's Encrypt configuration + domains: + type: array + description: List of domains associated with the Let's Encrypt configuration + email: + type: string + description: Email associated with the Let's Encrypt account + id: + type: integer + description: ID of the Let's Encrypt configuration + server_id: + type: integer + description: ID of the server + type: + type: string + description: Type of the Let's Encrypt configuration + enum: ['standalone', 'route53', 'cloudflare', 'digitalocean', 'linode'] + responses: + 201: + description: Let's Encrypt configuration created successfully + """ + try: + self._create_env(body) + last_id = le_sql.insert_le(**body.model_dump(mode='json')) + return IdResponse(id=last_id).model_dump(), 201 + except Exception as e: + return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot create Let\'s Encrypt') + + @validate(body=LetsEncryptRequest, query=GroupQuery) + def put(self, le_id: int, body: LetsEncryptRequest, query: GroupQuery): + """ + Update a Let's Encrypt configuration. + --- + tags: + - Let's Encrypt + parameters: + - name: le_id + in: path + type: integer + required: true + description: ID of the Let's Encrypt configuration + - name: group_id + in: query + type: integer + required: false + description: ID of the group (only for role superAdmin) + - name: body + in: body + required: true + schema: + type: object + properties: + api_key: + type: string + description: API key + api_token: + type: string + description: API token + description: + type: string + description: Description of the Let's Encrypt configuration + domains: + type: array + description: List of domains associated with the Let's Encrypt configuration + email: + type: string + description: Email associated with the Let's Encrypt account + id: + type: integer + description: ID of the Let's Encrypt configuration + server_id: + type: integer + description: ID of the server + type: + type: string + description: Type of the Let's Encrypt configuration + enum: ['standalone', 'route53', 'cloudflare', 'digitalocean', 'linode'] + responses: + 201: + description: Let's Encrypt configuration updated successfully + """ + group_id = SupportClass.return_group_id(query) + try: + le = le_sql.get_le_with_group(le_id, group_id) + except Exception as e: + return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot find Let\'s Encrypt') + + try: + le_dict = _return_domains_list(le) + data = LetsEncryptRequest(**le_dict) + self._create_env(data, 'delete') + except Exception as e: + return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot update Let\'s Encrypt on server') + + try: + le_sql.update_le(le_id, **body.model_dump(mode='json')) + self._create_env(body, 'install') + return IdResponse(id=le_id).model_dump(), 201 + except Exception as e: + return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot update Let\'s Encrypt') + + @validate(query=GroupQuery) + def delete(self, le_id: int, query: GroupQuery): + """ + Delete Let's Encrypt details. + --- + tags: + - Let's Encrypt + parameters: + - name: le_id + in: path + type: integer + required: true + description: ID of the Let's Encrypt configuration + - name: group_id + in: query + type: integer + required: false + description: ID of the group (only for role superAdmin) + responses: + 204: + description: Let's Encrypt deleted successfully + """ + group_id = SupportClass.return_group_id(query) + try: + le = le_sql.get_le_with_group(le_id, group_id) + except Exception as e: + return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot find Let\'s Encrypt') + + try: + le_dict = _return_domains_list(le) + le_dict['emails'] = None + data = LetsEncryptDeleteRequest(**le_dict) + self._create_env(data, action='delete') + except Exception as e: + return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot delete Let\'s Encrypt from server') + + try: + le_sql.delete_le(le_id) + return BaseResponse().model_dump(mode='json'), 204 + except Exception as e: + return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot delete Let\'s Encrypt') + + def _create_env(self, data: Union[LetsEncryptRequest, LetsEncryptDeleteRequest], action: str = 'install' ): + server_ips = [] + server_ip = 'localhost' + domains_command = '' + servers = {} + main_domain = data.domains[0] + inv = {"server": {"hosts": {}}} + masters = server_sql.is_master(server_ip) + ssl_path = common.return_nice_path(sql.get_setting('cert_path'), is_service=0) + + if data.type == 'standalone': + server_ip = server_sql.get_server_by_id(data.server_id).ip + ssh_settings = return_ssh_keys_path(server_ip) + servers[server_ip] = f"{ssh_settings['user']}@{ssh_settings['key']}" + ansible_role = 'letsencrypt_standalone' + else: + master_ip = server_sql.get_server_by_id(data.server_id).ip + ssh_settings = return_ssh_keys_path(master_ip) + servers[master_ip] = f"{ssh_settings['user']}@{ssh_settings['key']}" + ansible_role = 'letsencrypt' + + for domain in data.domains: + domains_command += f' -d {domain}' + + for master in masters: + if master[0] is not None: + ssh_settings = return_ssh_keys_path(master[0]) + servers[master[0]] = f"{ssh_settings['user']}@{ssh_settings['key']}" + + inv['server']['hosts'][master[0]] = { + 'token': data.api_token, + 'secret_key': data.api_key, + 'email': data.email, + 'ssl_path': ssl_path, + 'domains_command': domains_command, + 'main_domain': main_domain, + 'servers': servers, + 'action': action, + 'cert_type': data.type + } + server_ips.append(master[0]) + + inv['server']['hosts'][server_ip] = { + 'token': data.api_token, + 'secret_key': data.api_key, + 'email': data.email, + 'ssl_path': ssl_path, + 'domains_command': domains_command, + 'main_domain': main_domain, + 'servers': servers, + 'action': action, + 'cert_type': data.type + } + + server_ips.append(server_ip) + if data.type != 'standalone': + try: + output = service_mod.run_ansible_locally(inv, ansible_role) + except Exception as e: + raise e + else: + try: + output = service_mod.run_ansible(inv, server_ips, 'letsencrypt') + except Exception as e: + raise e + + if len(output['failures']) > 0 or len(output['dark']) > 0: + raise Exception('Cannot create certificate. Check Apache error log') + + +class LetsEncryptsView(MethodView): + methods = ['GET'] + decorators = [jwt_required(), get_user_params(), page_for_admin(level=3), check_group()] + + @validate(query=GroupQuery) + def get(self, query: GroupQuery): + """ + Get all Let's Encrypt configurations. + --- + tags: + - Let's Encrypt + parameters: + - name: group_id + in: query + type: integer + required: false + description: ID of the group (only for role superAdmin) + responses: + 200: + description: List of Let's Encrypt configurations retrieved successfully + schema: + type: array + items: + type: object + properties: + api_key: + type: string + description: API key + api_token: + type: string + description: API token + description: + type: string + description: Description of the Let's Encrypt configuration + domains: + type: array + items: + type: string + description: Domains associated with the Let's Encrypt configuration + email: + type: string + description: Email associated with the Let's Encrypt account + id: + type: integer + description: ID of the Let's Encrypt configuration + server_id: + type: integer + description: ID of the server + type: + type: string + description: Type of the Let's Encrypt configuration + """ + group_id = SupportClass.return_group_id(query) + le_list = [] + try: + les = le_sql.select_le_with_group(group_id) + for le in les: + le_list.append(_return_domains_list(le, query.recurse)) + return jsonify(le_list) + except Exception as e: + return roxywi_common.handler_exceptions_for_json_data(e, 'Cannot get Let\'s Encrypts') + + +def _return_domains_list(le: LetsEncrypt, recurse: bool = False) -> dict: + le_dict = model_to_dict(le, recurse=recurse) + le_dict['domains'] = ast.literal_eval(le_dict['domains']) + return le_dict diff --git a/requirements.txt b/requirements.txt index dd49baa3..9ced0679 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,7 @@ Flask-APScheduler==1.13.1 Flask-Caching==2.3.0 Flask-JWT-Extended==4.6.0 Flask-Pydantic==0.12.0 +pydantic[email] flask-swagger==0.2.14 ansible-core==2.16.3 ansible-runner==2.3.1