diff options
Diffstat (limited to 'src')
31 files changed, 486 insertions, 234 deletions
| diff --git a/src/conf_mode/interfaces-openvpn.py b/src/conf_mode/interfaces-openvpn.py index 5d537dadf..ae35ed3c4 100755 --- a/src/conf_mode/interfaces-openvpn.py +++ b/src/conf_mode/interfaces-openvpn.py @@ -51,6 +51,7 @@ user = 'openvpn'  group = 'openvpn'  cfg_file = '/run/openvpn/{ifname}.conf' +service_file = '/run/systemd/system/openvpn@{ifname}.service.d/20-override.conf'  def checkCertHeader(header, filename):      """ @@ -434,6 +435,11 @@ def generate(openvpn):      if os.path.isdir(ccd_dir):          rmtree(ccd_dir, ignore_errors=True) +    # Remove systemd directories with overrides +    service_dir = os.path.dirname(service_file.format(**openvpn)) +    if os.path.isdir(service_dir): +        rmtree(service_dir, ignore_errors=True) +      if 'deleted' in openvpn or 'disable' in openvpn:          return None @@ -477,14 +483,20 @@ def generate(openvpn):      render(cfg_file.format(**openvpn), 'openvpn/server.conf.tmpl', openvpn,             formater=lambda _: _.replace(""", '"'), user=user, group=group) +    # Render 20-override.conf for OpenVPN service +    render(service_file.format(**openvpn), 'openvpn/service-override.conf.tmpl', openvpn, +           formater=lambda _: _.replace(""", '"'), user=user, group=group) +    # Reload systemd services config to apply an override +    call(f'systemctl daemon-reload') +      return None  def apply(openvpn):      interface = openvpn['ifname'] -    call(f'systemctl stop openvpn@{interface}.service')      # Do some cleanup when OpenVPN is disabled/deleted      if 'deleted' in openvpn or 'disable' in openvpn: +        call(f'systemctl stop openvpn@{interface}.service')          for cleanup_file in glob(f'/run/openvpn/{interface}.*'):              if os.path.isfile(cleanup_file):                  os.unlink(cleanup_file) @@ -496,7 +508,7 @@ def apply(openvpn):      # No matching OpenVPN process running - maybe it got killed or none      # existed - nevertheless, spawn new OpenVPN process -    call(f'systemctl start openvpn@{interface}.service') +    call(f'systemctl reload-or-restart openvpn@{interface}.service')      conf = VTunIf.get_config()      conf['device_type'] = openvpn['device_type'] diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index 31c599145..cb46b3723 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -25,7 +25,9 @@ from vyos.configverify import verify_interface_exists  from vyos.configverify import verify_vrf  from vyos.ifconfig import WWANIf  from vyos.util import cmd +from vyos.util import call  from vyos.util import dict_search +from vyos.util import DEVNULL  from vyos.template import render  from vyos import ConfigError  from vyos import airbag @@ -89,7 +91,7 @@ def apply(wwan):          options += ',user={user},password={password}'.format(**wwan['authentication'])      command = f'{base_cmd} --simple-connect="{options}"' -    cmd(command) +    call(command, stdout=DEVNULL)      w.update(wwan)      return None diff --git a/src/conf_mode/snmp.py b/src/conf_mode/snmp.py index 3990e5735..0fbe90cce 100755 --- a/src/conf_mode/snmp.py +++ b/src/conf_mode/snmp.py @@ -20,13 +20,17 @@ from sys import exit  from vyos.config import Config  from vyos.configverify import verify_vrf -from vyos.snmpv3_hashgen import plaintext_to_md5, plaintext_to_sha1, random +from vyos.snmpv3_hashgen import plaintext_to_md5 +from vyos.snmpv3_hashgen import plaintext_to_sha1 +from vyos.snmpv3_hashgen import random  from vyos.template import render  from vyos.template import is_ipv4 -from vyos.util import call, chmod_755 +from vyos.util import call +from vyos.util import chmod_755  from vyos.validate import is_addr_assigned  from vyos.version import get_version_data -from vyos import ConfigError, airbag +from vyos import ConfigError +from vyos import airbag  airbag.enable()  config_file_client  = r'/etc/snmp/snmp.conf' @@ -401,19 +405,20 @@ def verify(snmp):          addr = listen[0]          port = listen[1] +        tmp = None          if is_ipv4(addr):              # example: udp:127.0.0.1:161 -            listen = 'udp:' + addr + ':' + port +            tmp = f'udp:{addr}:{port}'          elif snmp['ipv6_enabled']:              # example: udp6:[::1]:161 -            listen = 'udp6:' + '[' + addr + ']' + ':' + port +            tmp = f'udp6:[{addr}]:{port}'          # We only wan't to configure addresses that exist on the system.          # Hint the user if they don't exist          if is_addr_assigned(addr): -            snmp['listen_on'].append(listen) +            if tmp: snmp['listen_on'].append(tmp)          else: -            print('WARNING: SNMP listen address {0} not configured!'.format(addr)) +            print(f'WARNING: SNMP listen address {addr} not configured!')      verify_vrf(snmp) diff --git a/src/conf_mode/system-login-banner.py b/src/conf_mode/system-login-banner.py index a40d932e0..2220d7b66 100755 --- a/src/conf_mode/system-login-banner.py +++ b/src/conf_mode/system-login-banner.py @@ -37,7 +37,7 @@ PRELOGIN_NET_FILE = r'/etc/issue.net'  POSTLOGIN_FILE = r'/etc/motd'  default_config_data = { -    'issue': 'Welcome to VyOS - \n \l\n', +    'issue': 'Welcome to VyOS - \\n \\l\n\n',      'issue_net': 'Welcome to VyOS\n',      'motd': motd  } diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index e970d2ef5..86aa9af09 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -291,6 +291,8 @@ def get_config(config=None):      # LNS secret      if conf.exists(['lns', 'shared-secret']):          l2tp['lns_shared_secret'] = conf.return_value(['lns', 'shared-secret']) +    if conf.exists(['lns', 'host-name']): +        l2tp['lns_host_name'] = conf.return_value(['lns', 'host-name'])      if conf.exists(['ccp-disable']):          l2tp['ccp_disable'] = True diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index 070009722..215dfb37a 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -56,24 +56,13 @@ def verify(sstp):      #      # SSL certificate checks      # -    tmp = dict_search('ssl.ca_cert_file', sstp) -    if tmp: -        if not os.path.isfile(tmp): -            raise ConfigError(f'SSL CA certificate "{tmp}" does not exist!') -      tmp = dict_search('ssl.cert_file', sstp)      if not tmp:          raise ConfigError(f'SSL public key file required!') -    else: -        if not os.path.isfile(tmp): -            raise ConfigError(f'SSL public key "{tmp}" does not exist!')      tmp = dict_search('ssl.key_file', sstp)      if not tmp:          raise ConfigError(f'SSL private key file required!') -    else: -        if not os.path.isfile(tmp): -            raise ConfigError(f'SSL private key "{tmp}" does not exist!')  def generate(sstp):      if not sstp: diff --git a/src/etc/cron.d/check-wwan b/src/etc/cron.d/check-wwan new file mode 100644 index 000000000..28190776f --- /dev/null +++ b/src/etc/cron.d/check-wwan @@ -0,0 +1 @@ +*/5 * * * * root /usr/libexec/vyos/vyos-check-wwan.py diff --git a/src/etc/systemd/system/openvpn@.service.d/override.conf b/src/etc/systemd/system/openvpn@.service.d/10-override.conf index 03fe6b587..775a2d7ba 100644 --- a/src/etc/systemd/system/openvpn@.service.d/override.conf +++ b/src/etc/systemd/system/openvpn@.service.d/10-override.conf @@ -7,6 +7,7 @@ WorkingDirectory=  WorkingDirectory=/run/openvpn  ExecStart=  ExecStart=/usr/sbin/openvpn --daemon openvpn-%i --config %i.conf --status %i.status 30 --writepid %i.pid +ExecReload=/bin/kill -HUP $MAINPID  User=openvpn  Group=openvpn  AmbientCapabilities=CAP_IPC_LOCK CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_SYS_CHROOT CAP_DAC_OVERRIDE CAP_AUDIT_WRITE diff --git a/src/helpers/vyos-check-wwan.py b/src/helpers/vyos-check-wwan.py new file mode 100755 index 000000000..c6e6c54b7 --- /dev/null +++ b/src/helpers/vyos-check-wwan.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. + +from vyos.configquery import VbashOpRun +from vyos.configquery import ConfigTreeQuery + +from vyos.util import is_wwan_connected +from vyos.util import call + +conf = ConfigTreeQuery() +dict = conf.get_config_dict(['interfaces', 'wwan'], key_mangling=('-', '_'), +                            get_first_key=True) + +for interface, interface_config in dict.items(): +    if not is_wwan_connected(interface): +        if 'disable' in interface_config: +            # do not restart this interface as it's disabled by the user +            continue + +        #op = VbashOpRun() +        #op.run(['connect', 'interface', interface]) +        call(f'VYOS_TAGNODE_VALUE={interface} /usr/libexec/vyos/conf_mode/interfaces-wwan.py') + +exit(0) diff --git a/src/op_mode/connect_disconnect.py b/src/op_mode/connect_disconnect.py index a773aa28e..ffc574362 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2021 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -17,21 +17,19 @@  import os  import argparse -from sys import exit  from psutil import process_iter -from time import strftime, localtime, time  from vyos.util import call +from vyos.util import DEVNULL +from vyos.util import is_wwan_connected -def check_interface(interface): +def check_ppp_interface(interface):      if not os.path.isfile(f'/etc/ppp/peers/{interface}'): -        print(f'Interface {interface}: invalid!') +        print(f'Interface {interface} does not exist!')          exit(1)  def check_ppp_running(interface): -    """ -    Check if ppp process is running in the interface in question -    """ +    """ Check if PPP process is running in the interface in question """      for p in process_iter():          if "pppd" in p.name():              if interface in p.cmdline(): @@ -40,32 +38,46 @@ def check_ppp_running(interface):      return False  def connect(interface): -    """ -    Connect PPP interface -    """ -    check_interface(interface) +    """ Connect dialer interface """ -    # Check if interface is already dialed -    if os.path.isdir(f'/sys/class/net/{interface}'): -        print(f'Interface {interface}: already connected!') -    elif check_ppp_running(interface): -        print(f'Interface {interface}: connection is beeing established!') +    if interface.startswith('ppp'): +        check_ppp_interface(interface) +        # Check if interface is already dialed +        if os.path.isdir(f'/sys/class/net/{interface}'): +            print(f'Interface {interface}: already connected!') +        elif check_ppp_running(interface): +            print(f'Interface {interface}: connection is beeing established!') +        else: +            print(f'Interface {interface}: connecting...') +            call(f'systemctl restart ppp@{interface}.service') +    elif interface.startswith('wwan'): +        if is_wwan_connected(interface): +            print(f'Interface {interface}: already connected!') +        else: +            call(f'VYOS_TAGNODE_VALUE={interface} /usr/libexec/vyos/conf_mode/interfaces-wwan.py')      else: -        print(f'Interface {interface}: connecting...') -        call(f'systemctl restart ppp@{interface}.service') +        print(f'Unknown interface {interface}, can not connect. Aborting!')  def disconnect(interface): -    """ -    Disconnect PPP interface -    """ -    check_interface(interface) +    """ Disconnect dialer interface """ -    # Check if interface is already down -    if not check_ppp_running(interface): -        print(f'Interface {interface}: connection is already down') +    if interface.startswith('ppp'): +        check_ppp_interface(interface) + +        # Check if interface is already down +        if not check_ppp_running(interface): +            print(f'Interface {interface}: connection is already down') +        else: +            print(f'Interface {interface}: disconnecting...') +            call(f'systemctl stop ppp@{interface}.service') +    elif interface.startswith('wwan'): +        if not is_wwan_connected(interface): +            print(f'Interface {interface}: connection is already down') +        else: +            modem = interface.lstrip('wwan') +            call(f'mmcli --modem {modem} --simple-disconnect', stdout=DEVNULL)      else: -        print(f'Interface {interface}: disconnecting...') -        call(f'systemctl stop ppp@{interface}.service') +        print(f'Unknown interface {interface}, can not disconnect. Aborting!')  def main():      parser = argparse.ArgumentParser() diff --git a/src/op_mode/show_interfaces.py b/src/op_mode/show_interfaces.py index aef2d8060..281d25e30 100755 --- a/src/op_mode/show_interfaces.py +++ b/src/op_mode/show_interfaces.py @@ -85,10 +85,8 @@ def split_text(text, used=0):      used: number of characted already used in the screen      """      no_tty = call('tty -s') -    if no_tty: -        return text.split() -    returned = cmd('stty size') +    returned = cmd('stty size') if not no_tty else ''      if len(returned) == 2:          rows, columns = [int(_) for _ in returned]      else: diff --git a/src/op_mode/show_ipsec_sa.py b/src/op_mode/show_ipsec_sa.py index 503366dd8..beb632fa8 100755 --- a/src/op_mode/show_ipsec_sa.py +++ b/src/op_mode/show_ipsec_sa.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright (C) 2019-2021 VyOS maintainers and contributors  #  # This program is free software; you can redistribute it and/or modify  # it under the terms of the GNU General Public License version 2 or later as @@ -29,35 +29,22 @@ def convert(text):  def alphanum_key(key):      return [convert(c) for c in re.split('([0-9]+)', str(key))] -try: -    session = vici.Session() -    sas = session.list_sas() -except PermissionError: -    print("You do not have a permission to connect to the IPsec daemon") -    sys.exit(1) -except ConnectionRefusedError: -    print("IPsec is not runing") -    sys.exit(1) -except Exception as e: -    print("An error occured: {0}".format(e)) -    sys.exit(1) - -sa_data = [] - -for sa in sas: -    # list_sas() returns a list of single-item dicts -    for peer in sa: -        parent_sa = sa[peer] -        child_sas = parent_sa["child-sas"] -        installed_sas = {k: v for k, v in child_sas.items() if v["state"] == b"INSTALLED"} - -        # parent_sa["state"] = IKE state, child_sas["state"] = ESP state +def format_output(conns, sas): +    sa_data = [] + +    for peer, parent_conn in conns.items(): +        if peer not in sas: +            continue + +        parent_sa = sas[peer] +        child_sas = parent_sa['child-sas'] +        installed_sas = {v['name'].decode(): v for k, v in child_sas.items() if v["state"] == b"INSTALLED"} + +        state = 'down' +        uptime = 'N/A' +          if parent_sa["state"] == b"ESTABLISHED" and installed_sas:              state = "up" -        else: -            state = "down" - -        uptime = "N/A"          remote_host = parent_sa["remote-host"].decode()          remote_id = parent_sa["remote-id"].decode() @@ -66,53 +53,79 @@ for sa in sas:              remote_id = "N/A"          # The counters can only be obtained from the child SAs -        if not installed_sas: -            data = [peer, state, "N/A", "N/A", "N/A", "N/A", "N/A", "N/A"] -            sa_data.append(data) -        else: -            for csa in installed_sas: -                isa = installed_sas[csa] -                csa_name = isa['name'] -                csa_name = csa_name.decode() - -                bytes_in = hurry.filesize.size(int(isa["bytes-in"].decode())) -                bytes_out = hurry.filesize.size(int(isa["bytes-out"].decode())) -                bytes_str = "{0}/{1}".format(bytes_in, bytes_out) - -                pkts_in = hurry.filesize.size(int(isa["packets-in"].decode()), system=hurry.filesize.si) -                pkts_out = hurry.filesize.size(int(isa["packets-out"].decode()), system=hurry.filesize.si) -                pkts_str = "{0}/{1}".format(pkts_in, pkts_out) -                # Remove B from <1K values -                pkts_str = re.sub(r'B', r'', pkts_str) - -                uptime = vyos.util.seconds_to_human(isa['install-time'].decode()) - -                enc = isa["encr-alg"].decode() -                if "encr-keysize" in isa: -                    key_size = isa["encr-keysize"].decode() -                else: -                    key_size = "" -                if "integ-alg" in isa: -                    hash = isa["integ-alg"].decode() -                else: -                    hash = "" -                if "dh-group" in isa: -                    dh_group = isa["dh-group"].decode() -                else: -                    dh_group = "" - -                proposal = enc -                if key_size: -                    proposal = "{0}_{1}".format(proposal, key_size) -                if hash: -                    proposal = "{0}/{1}".format(proposal, hash) -                if dh_group: -                    proposal = "{0}/{1}".format(proposal, dh_group) - -                data = [csa_name, state, uptime, bytes_str, pkts_str, remote_host, remote_id, proposal] +        for child_conn in parent_conn['children']: +            if child_conn not in installed_sas: +                data = [child_conn, "down", "N/A", "N/A", "N/A", "N/A", "N/A", "N/A"]                  sa_data.append(data) - -headers = ["Connection", "State", "Uptime", "Bytes In/Out", "Packets In/Out", "Remote address", "Remote ID", "Proposal"] -sa_data = sorted(sa_data, key=alphanum_key) -output = tabulate.tabulate(sa_data, headers) -print(output) +                continue + +            isa = installed_sas[child_conn] +            csa_name = isa['name'] +            csa_name = csa_name.decode() + +            bytes_in = hurry.filesize.size(int(isa["bytes-in"].decode())) +            bytes_out = hurry.filesize.size(int(isa["bytes-out"].decode())) +            bytes_str = "{0}/{1}".format(bytes_in, bytes_out) + +            pkts_in = hurry.filesize.size(int(isa["packets-in"].decode()), system=hurry.filesize.si) +            pkts_out = hurry.filesize.size(int(isa["packets-out"].decode()), system=hurry.filesize.si) +            pkts_str = "{0}/{1}".format(pkts_in, pkts_out) +            # Remove B from <1K values +            pkts_str = re.sub(r'B', r'', pkts_str) + +            uptime = vyos.util.seconds_to_human(isa['install-time'].decode()) + +            enc = isa["encr-alg"].decode() +            if "encr-keysize" in isa: +                key_size = isa["encr-keysize"].decode() +            else: +                key_size = "" +            if "integ-alg" in isa: +                hash = isa["integ-alg"].decode() +            else: +                hash = "" +            if "dh-group" in isa: +                dh_group = isa["dh-group"].decode() +            else: +                dh_group = "" + +            proposal = enc +            if key_size: +                proposal = "{0}_{1}".format(proposal, key_size) +            if hash: +                proposal = "{0}/{1}".format(proposal, hash) +            if dh_group: +                proposal = "{0}/{1}".format(proposal, dh_group) + +            data = [csa_name, state, uptime, bytes_str, pkts_str, remote_host, remote_id, proposal] +            sa_data.append(data) +    return sa_data + +if __name__ == '__main__': +    try: +        session = vici.Session() +        conns = {} +        sas = {} + +        for conn in session.list_conns(): +            for key in conn: +                conns[key] = conn[key] + +        for sa in session.list_sas(): +            for key in sa: +                sas[key] = sa[key] + +        headers = ["Connection", "State", "Uptime", "Bytes In/Out", "Packets In/Out", "Remote address", "Remote ID", "Proposal"] +        sa_data = format_output(conns, sas) +        sa_data = sorted(sa_data, key=alphanum_key) +        output = tabulate.tabulate(sa_data, headers) +        print(output) +    except PermissionError: +        print("You do not have a permission to connect to the IPsec daemon") +        sys.exit(1) +    except ConnectionRefusedError: +        print("IPsec is not runing") +        sys.exit(1) +    except Exception as e: +        print("An error occured: {0}".format(e)) +        sys.exit(1) diff --git a/src/services/api/graphql/README.graphql b/src/services/api/graphql/README.graphql index 580c0eb7f..29f58f709 100644 --- a/src/services/api/graphql/README.graphql +++ b/src/services/api/graphql/README.graphql @@ -10,7 +10,7 @@ to run with that address as default router by requesting these 'mutations'  in the GraphQL playground:  mutation { -  createInterfaceEthernet (data: {interface: "eth1", +  CreateInterfaceEthernet (data: {interface: "eth1",                                    address: "192.168.0.1/24",                                    description: "BOB"}) {      success @@ -22,7 +22,7 @@ mutation {  }  mutation { -  createDhcpServer(data: {sharedNetworkName: "BOB", +  CreateDhcpServer(data: {sharedNetworkName: "BOB",                            subnet: "192.168.0.0/24",                            defaultRouter: "192.168.0.1",                            nameServer: "192.168.0.1", @@ -42,6 +42,38 @@ mutation {    }  } +To save the configuration, use the following mutation: + +mutation { +  SaveConfigFile(data: {fileName: "/config/config.boot"}) { +    success +    errors +    data { +      fileName +    } +  } +} + +N.B. fileName can be empty (fileName: "") or data can be empty (data: {}) to +save to /config/config.boot; to save to an alternative path, specify +fileName. + +Similarly, using the same 'endpoint' (meaning the form of the request and +resolver; the actual enpoint for all GraphQL requests is +https://hostname/graphql), one can load an arbitrary config file from a +path. + +mutation { +  LoadConfigFile(data: {fileName: "/home/vyos/config.boot"}) { +    success +    errors +    data { +      fileName +    } +  } +} + +  The GraphQL playground will be found at:  https://{{ host_address }}/graphql @@ -57,22 +89,23 @@ What's here:  services  ├── api  │   └── graphql +│       ├── bindings.py  │       ├── graphql  │       │   ├── directives.py  │       │   ├── __init__.py  │       │   ├── mutations.py  │       │   └── schema +│       │       ├── config_file.graphql  │       │       ├── dhcp_server.graphql  │       │       ├── interface_ethernet.graphql  │       │       └── schema.graphql +│       ├── README.graphql  │       ├── recipes -│       │   ├── dhcp_server.py  │       │   ├── __init__.py -│       │   ├── interface_ethernet.py -│       │   ├── recipe.py +│       │   ├── session.py  │       │   └── templates -│       │       ├── dhcp_server.tmpl -│       │       └── interface_ethernet.tmpl +│       │       ├── create_dhcp_server.tmpl +│       │       └── create_interface_ethernet.tmpl  │       └── state.py  ├── vyos-configd  ├── vyos-hostsd @@ -90,13 +123,14 @@ the Ur-data; the GraphQL schema is produced from those files, located in  Resolvers for the schema Mutation fields are dynamically generated using a  'directive' added to the respective schema field. The directive, -'@generate', is handled by the class 'DataDirective' in -'api/graphql/graphql/directives.py', which calls the 'make_resolver' function in -'api/graphql/graphql/mutations.py'; the produced resolver calls the appropriate -wrapper in 'api/graphql/recipes', with base class doing the (overridable) -configuration steps of calling all defined 'set'/'delete' commands. - -Integrating the above with vyos-http-api-server is ~10 lines of code. +'@configure', is handled by the class 'ConfigureDirective' in +'api/graphql/graphql/directives.py', which calls the +'make_configure_resolver' function in 'api/graphql/graphql/mutations.py'; +the produced resolver calls the appropriate wrapper in +'api/graphql/recipes', with base class doing the (overridable) configuration +steps of calling all defined 'set'/'delete' commands. + +Integrating the above with vyos-http-api-server is 4 lines of code.  What needs to be done: diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py new file mode 100644 index 000000000..1fbe13d0c --- /dev/null +++ b/src/services/api/graphql/bindings.py @@ -0,0 +1,13 @@ +import vyos.defaults +from . graphql.mutations import mutation +from . graphql.directives import directives_dict +from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers + +def generate_schema(): +    api_schema_dir = vyos.defaults.directories['api_schema'] + +    type_defs = load_schema_from_path(api_schema_dir) + +    schema = make_executable_schema(type_defs, mutation, snake_case_fallback_resolvers, directives=directives_dict) + +    return schema diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py index 651421c35..f5cd88acd 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -1,12 +1,11 @@  from ariadne import SchemaDirectiveVisitor, ObjectType -from . mutations import make_resolver +from . mutations import make_configure_resolver, make_config_file_resolver -class DataDirective(SchemaDirectiveVisitor): -    """ -    Class providing implementation of 'generate' directive in schema. +def non(arg): +    pass -    """ -    def visit_field_definition(self, field, object_type): +class VyosDirective(SchemaDirectiveVisitor): +    def visit_field_definition(self, field, object_type, make_resolver=non):          name = f'{field.type}'          # field.type contains the return value of the mutation; trim value          # to produce canonical name @@ -15,3 +14,24 @@ class DataDirective(SchemaDirectiveVisitor):          func = make_resolver(name)          field.resolve = func          return field + + +class ConfigureDirective(VyosDirective): +    """ +    Class providing implementation of 'configure' directive in schema. + +    """ +    def visit_field_definition(self, field, object_type): +        super().visit_field_definition(field, object_type, +                                       make_resolver=make_configure_resolver) + +class ConfigFileDirective(VyosDirective): +    """ +    Class providing implementation of 'configfile' directive in schema. + +    """ +    def visit_field_definition(self, field, object_type): +        super().visit_field_definition(field, object_type, +                                       make_resolver=make_config_file_resolver) + +directives_dict = {"configure": ConfigureDirective, "configfile": ConfigFileDirective} diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 98c665c9a..8a28b13d7 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -6,10 +6,11 @@ from graphql import GraphQLResolveInfo  from makefun import with_signature  from .. import state +from api.graphql.recipes.session import Session  mutation = ObjectType("Mutation") -def make_resolver(mutation_name): +def make_resolver(mutation_name, class_name, session_func):      """Dynamically generate a resolver for the mutation named in the      schema by 'mutation_name'. @@ -19,11 +20,11 @@ def make_resolver(mutation_name):      functools.wraps.      :raise Exception: -        encapsulating ConfigErrors, or internal errors +        raising ConfigErrors, or internal errors      """ -    class_name = mutation_name.replace('create', '', 1).replace('delete', '', 1) +      func_base_name = convert_camel_case_to_snake(class_name) -    resolver_name = f'resolve_create_{func_base_name}' +    resolver_name = f'resolve_{func_base_name}'      func_sig = '(obj: Any, info: GraphQLResolveInfo, data: Dict)'      @mutation.field(mutation_name) @@ -40,10 +41,17 @@ def make_resolver(mutation_name):              data = kwargs['data']              session = state.settings['app'].state.vyos_session -            mod = import_module(f'api.graphql.recipes.{func_base_name}') -            klass = getattr(mod, class_name) +            # one may override the session functions with a local subclass +            try: +                mod = import_module(f'api.graphql.recipes.{func_base_name}') +                klass = getattr(mod, class_name) +            except ImportError: +                # otherwise, dynamically generate subclass to invoke subclass +                # name based templates +                klass = type(class_name, (Session,), {})              k = klass(session, data) -            k.configure() +            method = getattr(k, session_func) +            method()              return {                  "success": True, @@ -57,4 +65,16 @@ def make_resolver(mutation_name):      return func_impl +def make_configure_resolver(mutation_name): +    class_name = mutation_name +    return make_resolver(mutation_name, class_name, 'configure') +def make_config_file_resolver(mutation_name): +    if 'Save' in mutation_name: +        class_name = mutation_name.replace('Save', '', 1) +        return make_resolver(mutation_name, class_name, 'save') +    elif 'Load' in mutation_name: +        class_name = mutation_name.replace('Load', '', 1) +        return make_resolver(mutation_name, class_name, 'load') +    else: +        raise Exception diff --git a/src/services/api/graphql/graphql/schema/config_file.graphql b/src/services/api/graphql/graphql/schema/config_file.graphql new file mode 100644 index 000000000..31ab26b9e --- /dev/null +++ b/src/services/api/graphql/graphql/schema/config_file.graphql @@ -0,0 +1,27 @@ +input SaveConfigFileInput { +    fileName: String +} + +type SaveConfigFile { +    fileName: String +} + +type SaveConfigFileResult { +    data: SaveConfigFile +    success: Boolean! +    errors: [String] +} + +input LoadConfigFileInput { +    fileName: String! +} + +type LoadConfigFile { +    fileName: String! +} + +type LoadConfigFileResult { +    data: LoadConfigFile +    success: Boolean! +    errors: [String] +} diff --git a/src/services/api/graphql/graphql/schema/dhcp_server.graphql b/src/services/api/graphql/graphql/schema/dhcp_server.graphql index 9f741a0a5..25f091bfa 100644 --- a/src/services/api/graphql/graphql/schema/dhcp_server.graphql +++ b/src/services/api/graphql/graphql/schema/dhcp_server.graphql @@ -1,4 +1,4 @@ -input dhcpServerConfigInput { +input DhcpServerConfigInput {      sharedNetworkName: String      subnet: String      defaultRouter: String @@ -13,7 +13,7 @@ input dhcpServerConfigInput {      dnsForwardingListenAddress: String  } -type dhcpServerConfig { +type DhcpServerConfig {      sharedNetworkName: String      subnet: String      defaultRouter: String @@ -28,8 +28,8 @@ type dhcpServerConfig {      dnsForwardingListenAddress: String  } -type createDhcpServerResult { -    data: dhcpServerConfig +type CreateDhcpServerResult { +    data: DhcpServerConfig      success: Boolean!      errors: [String]  } diff --git a/src/services/api/graphql/graphql/schema/firewall_group.graphql b/src/services/api/graphql/graphql/schema/firewall_group.graphql new file mode 100644 index 000000000..efe7de632 --- /dev/null +++ b/src/services/api/graphql/graphql/schema/firewall_group.graphql @@ -0,0 +1,47 @@ +input CreateFirewallAddressGroupInput { +    name: String! +    address: [String] +} + +type CreateFirewallAddressGroup { +    name: String! +    address: [String] +} + +type CreateFirewallAddressGroupResult { +    data: CreateFirewallAddressGroup +    success: Boolean! +    errors: [String] +} + +input UpdateFirewallAddressGroupMembersInput { +    name: String! +    address: [String!]! +} + +type UpdateFirewallAddressGroupMembers { +    name: String! +    address: [String!]! +} + +type UpdateFirewallAddressGroupMembersResult { +    data: UpdateFirewallAddressGroupMembers +    success: Boolean! +    errors: [String] +} + +input RemoveFirewallAddressGroupMembersInput { +    name: String! +    address: [String!]! +} + +type RemoveFirewallAddressGroupMembers { +    name: String! +    address: [String!]! +} + +type RemoveFirewallAddressGroupMembersResult { +    data: RemoveFirewallAddressGroupMembers +    success: Boolean! +    errors: [String] +} diff --git a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql index fdcf97bad..32438b315 100644 --- a/src/services/api/graphql/graphql/schema/interface_ethernet.graphql +++ b/src/services/api/graphql/graphql/schema/interface_ethernet.graphql @@ -1,18 +1,18 @@ -input interfaceEthernetConfigInput { +input InterfaceEthernetConfigInput {      interface: String      address: String      replace: Boolean = true      description: String  } -type interfaceEthernetConfig { +type InterfaceEthernetConfig {      interface: String      address: String      description: String  } -type createInterfaceEthernetResult { -    data: interfaceEthernetConfig +type CreateInterfaceEthernetResult { +    data: InterfaceEthernetConfig      success: Boolean!      errors: [String]  } diff --git a/src/services/api/graphql/graphql/schema/schema.graphql b/src/services/api/graphql/graphql/schema/schema.graphql index 8a5e17962..9e97a0d60 100644 --- a/src/services/api/graphql/graphql/schema/schema.graphql +++ b/src/services/api/graphql/graphql/schema/schema.graphql @@ -7,9 +7,15 @@ type Query {      _dummy: String  } -directive @generate on FIELD_DEFINITION +directive @configure on FIELD_DEFINITION +directive @configfile on FIELD_DEFINITION  type Mutation { -    createDhcpServer(data: dhcpServerConfigInput) : createDhcpServerResult @generate -    createInterfaceEthernet(data: interfaceEthernetConfigInput) : createInterfaceEthernetResult @generate +    CreateDhcpServer(data: DhcpServerConfigInput) : CreateDhcpServerResult @configure +    CreateInterfaceEthernet(data: InterfaceEthernetConfigInput) : CreateInterfaceEthernetResult @configure +    CreateFirewallAddressGroup(data: CreateFirewallAddressGroupInput) : CreateFirewallAddressGroupResult @configure +    UpdateFirewallAddressGroupMembers(data: UpdateFirewallAddressGroupMembersInput) : UpdateFirewallAddressGroupMembersResult @configure +    RemoveFirewallAddressGroupMembers(data: RemoveFirewallAddressGroupMembersInput) : RemoveFirewallAddressGroupMembersResult @configure +    SaveConfigFile(data: SaveConfigFileInput) : SaveConfigFileResult @configfile +    LoadConfigFile(data: LoadConfigFileInput) : LoadConfigFileResult @configfile  } diff --git a/src/services/api/graphql/recipes/dhcp_server.py b/src/services/api/graphql/recipes/dhcp_server.py deleted file mode 100644 index 3edb3028e..000000000 --- a/src/services/api/graphql/recipes/dhcp_server.py +++ /dev/null @@ -1,13 +0,0 @@ - -from . recipe import Recipe - -class DhcpServer(Recipe): -    def __init__(self, session, command_file): -        super().__init__(session, command_file) - -    # Define any custom processing of parameters here by overriding -    # configure: -    # -    # def configure(self): -    #     self.data = transform_data(self.data) -    #     super().configure() diff --git a/src/services/api/graphql/recipes/interface_ethernet.py b/src/services/api/graphql/recipes/interface_ethernet.py deleted file mode 100644 index f88f5924f..000000000 --- a/src/services/api/graphql/recipes/interface_ethernet.py +++ /dev/null @@ -1,13 +0,0 @@ - -from . recipe import Recipe - -class InterfaceEthernet(Recipe): -    def __init__(self, session, command_file): -        super().__init__(session, command_file) - -    # Define any custom processing of parameters here by overriding -    # configure: -    # -    # def configure(self): -    #     self.data = transform_data(self.data) -    #     super().configure() diff --git a/src/services/api/graphql/recipes/remove_firewall_address_group_members.py b/src/services/api/graphql/recipes/remove_firewall_address_group_members.py new file mode 100644 index 000000000..cde30c27a --- /dev/null +++ b/src/services/api/graphql/recipes/remove_firewall_address_group_members.py @@ -0,0 +1,21 @@ + +from . session import Session + +class RemoveFirewallAddressGroupMembers(Session): +    def __init__(self, session, data): +        super().__init__(session, data) + +    # Define any custom processing of parameters here by overriding +    # configure: +    # +    # def configure(self): +    #     self._data = transform_data(self._data) +    #     super().configure() +    #     self.clean_up() + +    def configure(self): +        super().configure() + +        group_name = self._data['name'] +        path = ['firewall', 'group', 'address-group', group_name] +        self.delete_path_if_childless(path) diff --git a/src/services/api/graphql/recipes/recipe.py b/src/services/api/graphql/recipes/session.py index 8fbb9e0bf..b96cc1753 100644 --- a/src/services/api/graphql/recipes/recipe.py +++ b/src/services/api/graphql/recipes/session.py @@ -1,27 +1,17 @@  from ariadne import convert_camel_case_to_snake  import vyos.defaults +from vyos.config import Config  from vyos.template import render -class Recipe(object): +class Session(object):      def __init__(self, session, data):          self._session = session -        self.data = data +        self._data = data          self._name = convert_camel_case_to_snake(type(self).__name__) -    @property -    def data(self): -        return self.__data - -    @data.setter -    def data(self, data): -        if isinstance(data, dict): -            self.__data = data -        else: -            raise ValueError("data must be of type dict") -      def configure(self):          session = self._session -        data = self.data +        data = self._data          func_base_name = self._name          tmpl_file = f'{func_base_name}.tmpl' @@ -46,4 +36,30 @@ class Recipe(object):          except Exception as error:              raise error +    def delete_path_if_childless(self, path): +        session = self._session +        config = Config(session.get_session_env()) +        if not config.list_nodes(path): +            session.delete(path) +            session.commit() + +    def save(self): +        session = self._session +        data = self._data +        if 'file_name' not in data or not data['file_name']: +            data['file_name'] = '/config/config.boot' + +        try: +            session.save_config(data['file_name']) +        except Exception as error: +            raise error + +    def load(self): +        session = self._session +        data = self._data +        try: +            session.load_config(data['file_name']) +            session.commit() +        except Exception as error: +            raise error diff --git a/src/services/api/graphql/recipes/templates/dhcp_server.tmpl b/src/services/api/graphql/recipes/templates/create_dhcp_server.tmpl index 70de43183..70de43183 100644 --- a/src/services/api/graphql/recipes/templates/dhcp_server.tmpl +++ b/src/services/api/graphql/recipes/templates/create_dhcp_server.tmpl diff --git a/src/services/api/graphql/recipes/templates/create_firewall_address_group.tmpl b/src/services/api/graphql/recipes/templates/create_firewall_address_group.tmpl new file mode 100644 index 000000000..a890d0086 --- /dev/null +++ b/src/services/api/graphql/recipes/templates/create_firewall_address_group.tmpl @@ -0,0 +1,4 @@ +set firewall group address-group {{ name }} +{% for add in address %} +set firewall group address-group {{ name }} address {{ add }} +{% endfor %} diff --git a/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl b/src/services/api/graphql/recipes/templates/create_interface_ethernet.tmpl index d9d7ed691..d9d7ed691 100644 --- a/src/services/api/graphql/recipes/templates/interface_ethernet.tmpl +++ b/src/services/api/graphql/recipes/templates/create_interface_ethernet.tmpl diff --git a/src/services/api/graphql/recipes/templates/remove_firewall_address_group_members.tmpl b/src/services/api/graphql/recipes/templates/remove_firewall_address_group_members.tmpl new file mode 100644 index 000000000..458f3e5fc --- /dev/null +++ b/src/services/api/graphql/recipes/templates/remove_firewall_address_group_members.tmpl @@ -0,0 +1,3 @@ +{% for add in address %} +delete firewall group address-group {{ name }} address {{ add }} +{% endfor %} diff --git a/src/services/api/graphql/recipes/templates/update_firewall_address_group_members.tmpl b/src/services/api/graphql/recipes/templates/update_firewall_address_group_members.tmpl new file mode 100644 index 000000000..f56c61231 --- /dev/null +++ b/src/services/api/graphql/recipes/templates/update_firewall_address_group_members.tmpl @@ -0,0 +1,3 @@ +{% for add in address %} +set firewall group address-group {{ name }} address {{ add }} +{% endfor %} diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index cb4ce4072..aa7ac6708 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -32,16 +32,13 @@ from fastapi.responses import HTMLResponse  from fastapi.exceptions import RequestValidationError  from fastapi.routing import APIRoute  from pydantic import BaseModel, StrictStr, validator -from starlette.datastructures import FormData, MutableHeaders +from starlette.datastructures import FormData  from starlette.formparsers import FormParser, MultiPartParser  from multipart.multipart import parse_options_header -from ariadne import make_executable_schema, load_schema_from_path, snake_case_fallback_resolvers  from ariadne.asgi import GraphQL  import vyos.config -import vyos.defaults -  from vyos.configsession import ConfigSession, ConfigSessionError  import api.graphql.state @@ -69,11 +66,11 @@ def load_server_config():      return config  def check_auth(key_list, key): -    id = None +    key_id = None      for k in key_list:          if k['key'] == key: -            id = k['id'] -    return id +            key_id = k['id'] +    return key_id  def error(code, msg):      resp = {"success": False, "error": msg, "data": None} @@ -223,10 +220,10 @@ responses = {  def auth_required(data: ApiModel):      key = data.key      api_keys = app.state.vyos_keys -    id = check_auth(api_keys, key) -    if not id: +    key_id = check_auth(api_keys, key) +    if not key_id:          raise HTTPException(status_code=401, detail="Valid API key is required") -    app.state.vyos_id = id +    app.state.vyos_id = key_id  # override Request and APIRoute classes in order to convert form request to json;  # do all explicit validation here, for backwards compatability of error messages; @@ -613,16 +610,11 @@ def show_op(data: ShowModel):  # GraphQL integration  ### -api.graphql.state.init() - -from api.graphql.graphql.mutations import mutation -from api.graphql.graphql.directives import DataDirective +from api.graphql.bindings import generate_schema -api_schema_dir = vyos.defaults.directories['api_schema'] - -type_defs = load_schema_from_path(api_schema_dir) +api.graphql.state.init() -schema = make_executable_schema(type_defs, mutation, snake_case_fallback_resolvers, directives={"generate": DataDirective}) +schema = generate_schema()  app.add_route('/graphql', GraphQL(schema, debug=True)) @@ -640,16 +632,16 @@ if __name__ == '__main__':      try:          server_config = load_server_config() -    except Exception as e: -        logger.critical("Failed to load the HTTP API server config: {0}".format(e)) +    except Exception as err: +        logger.critical(f"Failed to load the HTTP API server config: {err}") -    session = ConfigSession(os.getpid()) +    config_session = ConfigSession(os.getpid()) -    app.state.vyos_session = session +    app.state.vyos_session = config_session      app.state.vyos_keys = server_config['api_keys'] -    app.state.vyos_debug = True if server_config['debug'] == 'true' else False -    app.state.vyos_strict = True if server_config['strict'] == 'true' else False +    app.state.vyos_debug = bool(server_config['debug'] == 'true') +    app.state.vyos_strict = bool(server_config['strict'] == 'true')      api.graphql.state.settings['app'] = app @@ -657,6 +649,6 @@ if __name__ == '__main__':          uvicorn.run(app, host=server_config["listen_address"],                           port=int(server_config["port"]),                           proxy_headers=True) -    except OSError as e: -        logger.critical(f"OSError {e}") +    except OSError as err: +        logger.critical(f"OSError {err}")          sys.exit(1) | 
