diff options
Diffstat (limited to 'src')
| -rwxr-xr-x | src/conf_mode/interfaces_openvpn.py | 37 | ||||
| -rwxr-xr-x | src/conf_mode/system_option.py | 2 | ||||
| -rw-r--r-- | src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper | 18 | ||||
| -rw-r--r-- | src/op_mode/ntp.py | 45 | ||||
| -rwxr-xr-x | src/op_mode/pki.py | 41 | ||||
| -rwxr-xr-x | src/services/vyos-configd | 19 | ||||
| -rwxr-xr-x | src/services/vyos-http-api-server | 62 | 
7 files changed, 192 insertions, 32 deletions
| diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index 9105ce1f8..8c1213e2b 100755 --- a/src/conf_mode/interfaces_openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -123,6 +123,18 @@ def get_config(config=None):              openvpn['module_load_dco'] = {}              break +    # Calculate the protocol modifier. This is concatenated to the protocol string to direct +    # OpenVPN to use a specific IP protocol version. If unspecified, the kernel decides which +    # type of socket to open. In server mode, an additional "ipv6-dual-stack" option forces +    # binding the socket in IPv6 mode, which can also receive IPv4 traffic (when using the +    # default "ipv6" mode, we specify "bind ipv6only" to disable kernel dual-stack behaviors). +    if openvpn['ip_version'] == 'ipv4': +        openvpn['protocol_modifier'] = '4' +    elif openvpn['ip_version'] in ['ipv6', 'dual-stack']: +        openvpn['protocol_modifier']  = '6' +    else: +        openvpn['protocol_modifier'] = '' +      return openvpn  def is_ec_private_key(pki, cert_name): @@ -257,6 +269,9 @@ def verify(openvpn):          if openvpn['protocol'] == 'tcp-passive':              raise ConfigError('Protocol "tcp-passive" is not valid in client mode') +        if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack': +            raise ConfigError('"ip-version dual-stack" is not supported in client mode') +          if dict_search('tls.dh_params', openvpn):              raise ConfigError('Cannot specify "tls dh-params" in client mode') @@ -264,6 +279,9 @@ def verify(openvpn):      # OpenVPN site-to-site - VERIFY      #      elif openvpn['mode'] == 'site-to-site': +        if 'ip_version' in openvpn and openvpn['ip_version'] == 'dual-stack': +            raise ConfigError('"ip-version dual-stack" is not supported in site-to-site mode') +          if 'local_address' not in openvpn and 'is_bridge_member' not in openvpn:              raise ConfigError('Must specify "local-address" or add interface to bridge') @@ -487,6 +505,25 @@ def verify(openvpn):      # not depending on any operation mode      # +    # verify that local_host/remote_host match with any ip_version override +    # specified (if a dns name is specified for remote_host, no attempt is made +    # to verify that record resolves to an address of the configured family) +    if 'local_host' in openvpn: +        if openvpn['ip_version'] == 'ipv4' and is_ipv6(openvpn['local_host']): +            raise ConfigError('Cannot use an IPv6 "local-host" with "ip-version ipv4"') +        elif openvpn['ip_version'] == 'ipv6' and is_ipv4(openvpn['local_host']): +            raise ConfigError('Cannot use an IPv4 "local-host" with "ip-version ipv6"') +        elif openvpn['ip_version'] == 'dual-stack': +            raise ConfigError('Cannot use "local-host" with "ip-version dual-stack". "dual-stack" is only supported when OpenVPN binds to all available interfaces.') + +    if 'remote_host' in openvpn: +        remote_hosts = dict_search('remote_host', openvpn) +        for remote_host in remote_hosts: +            if openvpn['ip_version'] == 'ipv4' and is_ipv6(remote_host): +                raise ConfigError('Cannot use an IPv6 "remote-host" with "ip-version ipv4"') +            elif openvpn['ip_version'] == 'ipv6' and is_ipv4(remote_host): +                raise ConfigError('Cannot use an IPv4 "remote-host" with "ip-version ipv6"') +      # verify specified IP address is present on any interface on this system      if 'local_host' in openvpn:          if not is_addr_assigned(openvpn['local_host']): diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py index d1647e3a1..52d0b7cda 100755 --- a/src/conf_mode/system_option.py +++ b/src/conf_mode/system_option.py @@ -85,6 +85,8 @@ def verify(options):                  raise ConfigError('No interface with address "{address}" configured!')          if 'source_interface' in config: +            # verify_source_interface reuires key 'ifname' +            config['ifname'] = config['source_interface']              verify_source_interface(config)              if 'source_address' in config:                  address = config['source_address'] diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper index 5d879471d..2a1c5a7b2 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper +++ b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper @@ -72,6 +72,22 @@ function delroute () {      fi  } +# try to communicate with vtysh +function vtysh_conf () { +    # perform 10 attempts with 1 second delay for retries +    for i in {1..10} ; do +        if vtysh  -c "conf t" -c "$1" ; then +            logmsg info "Command was executed successfully via vtysh: \"$1\"" +            return 0 +        else +            logmsg info "Failed to send command to vtysh, retrying in 1 second" +            sleep 1 +        fi +    done +    logmsg error "Failed to execute command via vtysh after 10 attempts: \"$1\"" +    return 1 +} +  # replace ip command with this wrapper  function ip () {      # pass comand to system `ip` if this is not related to routes change @@ -84,7 +100,7 @@ function ip () {              delroute ${@:4}              iptovtysh $@              logmsg info "Sending command to vtysh" -            vtysh -c "conf t" -c "$VTYSH_CMD" +            vtysh_conf "$VTYSH_CMD"          else              # add ip route to kernel              logmsg info "Modifying routes in kernel: \"/usr/sbin/ip $@\"" diff --git a/src/op_mode/ntp.py b/src/op_mode/ntp.py index e14cc46d0..6ec0fedcb 100644 --- a/src/op_mode/ntp.py +++ b/src/op_mode/ntp.py @@ -110,49 +110,62 @@ def _is_configured():      if not config.exists("service ntp"):          raise vyos.opmode.UnconfiguredSubsystem("NTP service is not enabled.") +def _extend_command_vrf(): +    config = ConfigTreeQuery() +    if config.exists('service ntp vrf'): +        vrf = config.value('service ntp vrf') +        return f'ip vrf exec {vrf} ' +    return '' + +  def show_activity(raw: bool):      _is_configured()      command = f'chronyc'      if raw: -       command += f" -c activity" -       return _get_raw_data(command) +        command += f" -c activity" +        return _get_raw_data(command)      else: -       command += f" activity" -       return cmd(command) +        command = _extend_command_vrf() + command +        command += f" activity" +        return cmd(command)  def show_sources(raw: bool):      _is_configured()      command = f'chronyc'      if raw: -       command += f" -c sources" -       return _get_raw_data(command) +        command += f" -c sources" +        return _get_raw_data(command)      else: -       command += f" sources -v" -       return cmd(command) +        command = _extend_command_vrf() + command +        command += f" sources -v" +        return cmd(command)  def show_tracking(raw: bool):      _is_configured()      command = f'chronyc'      if raw: -       command += f" -c tracking" -       return _get_raw_data(command) +        command += f" -c tracking" +        return _get_raw_data(command)      else: -       command += f" tracking" -       return cmd(command) +        command = _extend_command_vrf() + command +        command += f" tracking" +        return cmd(command)  def show_sourcestats(raw: bool):      _is_configured()      command = f'chronyc'      if raw: -       command += f" -c sourcestats" -       return _get_raw_data(command) +        command += f" -c sourcestats" +        return _get_raw_data(command)      else: -       command += f" sourcestats -v" -       return cmd(command) +        command = _extend_command_vrf() + command +        command += f" sourcestats -v" +        return cmd(command) +  if __name__ == '__main__':      try: diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index 84b080023..ab613e5c4 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -316,7 +316,13 @@ def generate_certificate_request(private_key=None, key_type=None, return_request      default_values = get_default_values()      subject = {} -    subject['country'] = ask_input('Enter country code:', default=default_values['country']) +    while True: +        country = ask_input('Enter country code:', default=default_values['country']) +        if len(country) != 2: +            print("Country name must be a 2 character country code") +            continue +        subject['country'] = country +        break      subject['state'] = ask_input('Enter state:', default=default_values['state'])      subject['locality'] = ask_input('Enter locality:', default=default_values['locality'])      subject['organization'] = ask_input('Enter organization name:', default=default_values['organization']) @@ -693,7 +699,7 @@ def generate_wireguard_psk(interface=None, peer=None, install=False):          print(f'Pre-shared key: {psk}')  # Import functions -def import_ca_certificate(name, path=None, key_path=None): +def import_ca_certificate(name, path=None, key_path=None, no_prompt=False, passphrase=None):      if path:          if not os.path.exists(path):              print(f'File not found: {path}') @@ -717,19 +723,20 @@ def import_ca_certificate(name, path=None, key_path=None):              return          key = None -        passphrase = ask_input('Enter private key passphrase: ') or None +        if not no_prompt: +            passphrase = ask_input('Enter private key passphrase: ') or None          with open(key_path) as f:              key_data = f.read()              key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False)          if not key: -            print(f'Invalid private key or passphrase: {path}') +            print(f'Invalid private key or passphrase: {key_path}')              return          install_certificate(name, private_key=key, is_ca=True) -def import_certificate(name, path=None, key_path=None): +def import_certificate(name, path=None, key_path=None, no_prompt=False, passphrase=None):      if path:          if not os.path.exists(path):              print(f'File not found: {path}') @@ -753,14 +760,15 @@ def import_certificate(name, path=None, key_path=None):              return          key = None -        passphrase = ask_input('Enter private key passphrase: ') or None +        if not no_prompt: +            passphrase = ask_input('Enter private key passphrase: ') or None          with open(key_path) as f:              key_data = f.read()              key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False)          if not key: -            print(f'Invalid private key or passphrase: {path}') +            print(f'Invalid private key or passphrase: {key_path}')              return          install_certificate(name, private_key=key, is_ca=False) @@ -799,7 +807,7 @@ def import_dh_parameters(name, path):      install_dh_parameters(name, dh) -def import_keypair(name, path=None, key_path=None): +def import_keypair(name, path=None, key_path=None, no_prompt=False, passphrase=None):      if path:          if not os.path.exists(path):              print(f'File not found: {path}') @@ -823,14 +831,15 @@ def import_keypair(name, path=None, key_path=None):              return          key = None -        passphrase = ask_input('Enter private key passphrase: ') or None +        if not no_prompt: +            passphrase = ask_input('Enter private key passphrase: ') or None          with open(key_path) as f:              key_data = f.read()              key = load_private_key(key_data, passphrase=passphrase, wrap_tags=False)          if not key: -            print(f'Invalid private key or passphrase: {path}') +            print(f'Invalid private key or passphrase: {key_path}')              return          install_keypair(name, None, private_key=key, prompt=False) @@ -1011,6 +1020,9 @@ if __name__ == '__main__':      parser.add_argument('--filename', help='Write certificate into specified filename', action='store')      parser.add_argument('--key-filename', help='Write key into specified filename', action='store') +    parser.add_argument('--no-prompt', action='store_true', help='Perform action non-interactively') +    parser.add_argument('--passphrase', help='A passphrase to decrypt the private key') +      args = parser.parse_args()      try: @@ -1054,15 +1066,18 @@ if __name__ == '__main__':                      generate_wireguard_psk(args.interface, peer=args.peer, install=args.install)          elif args.action == 'import':              if args.ca: -                import_ca_certificate(args.ca, path=args.filename, key_path=args.key_filename) +                import_ca_certificate(args.ca, path=args.filename, key_path=args.key_filename, +                                      no_prompt=args.no_prompt, passphrase=args.passphrase)              elif args.certificate: -                import_certificate(args.certificate, path=args.filename, key_path=args.key_filename) +                import_certificate(args.certificate, path=args.filename, key_path=args.key_filename, +                                   no_prompt=args.no_prompt, passphrase=args.passphrase)              elif args.crl:                  import_crl(args.crl, args.filename)              elif args.dh:                  import_dh_parameters(args.dh, args.filename)              elif args.keypair: -                import_keypair(args.keypair, path=args.filename, key_path=args.key_filename) +                import_keypair(args.keypair, path=args.filename, key_path=args.key_filename, +                               no_prompt=args.no_prompt, passphrase=args.passphrase)              elif args.openvpn:                  import_openvpn_secret(args.openvpn, args.filename)          elif args.action == 'show': diff --git a/src/services/vyos-configd b/src/services/vyos-configd index d797e90cf..3674d9627 100755 --- a/src/services/vyos-configd +++ b/src/services/vyos-configd @@ -30,6 +30,7 @@ from vyos.defaults import directories  from vyos.utils.boot import boot_configuration_complete  from vyos.configsource import ConfigSourceString  from vyos.configsource import ConfigSourceError +from vyos.configdiff import get_commit_scripts  from vyos.config import Config  from vyos import ConfigError @@ -220,6 +221,12 @@ def initialization(socket):      dependent_func: dict[str, list[typing.Callable]] = {}      setattr(config, 'dependent_func', dependent_func) +    commit_scripts = get_commit_scripts(config) +    logger.debug(f'commit_scripts: {commit_scripts}') + +    scripts_called = [] +    setattr(config, 'scripts_called', scripts_called) +      return config  def process_node_data(config, data, last: bool = False) -> int: @@ -228,6 +235,7 @@ def process_node_data(config, data, last: bool = False) -> int:          return R_ERROR_DAEMON      script_name = None +    os.environ['VYOS_TAGNODE_VALUE'] = ''      args = []      config.dependency_list.clear() @@ -244,6 +252,12 @@ def process_node_data(config, data, last: bool = False) -> int:          args = res.group(3).split()      args.insert(0, f'{script_name}.py') +    tag_value = os.getenv('VYOS_TAGNODE_VALUE', '') +    tag_ext = f'_{tag_value}' if tag_value else '' +    script_record = f'{script_name}{tag_ext}' +    scripts_called = getattr(config, 'scripts_called', []) +    scripts_called.append(script_record) +      if script_name not in include_set:          return R_PASS @@ -302,11 +316,12 @@ if __name__ == '__main__':              socket.send(resp.encode())              config = initialization(socket)          elif message["type"] == "node": -            if message["last"]: -                logger.debug(f'final element of priority queue')              res = process_node_data(config, message["data"], message["last"])              response = res.to_bytes(1, byteorder=sys.byteorder)              logger.debug(f"Sending response {res}")              socket.send(response) +            if message["last"] and config: +                scripts_called = getattr(config, 'scripts_called', []) +                logger.debug(f'scripts_called: {scripts_called}')          else:              logger.critical(f"Unexpected message: {message}") diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 7f5233c6b..97633577d 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -212,6 +212,22 @@ class ImageModel(ApiModel):              }          } +class ImportPkiModel(ApiModel): +    op: StrictStr +    path: List[StrictStr] +    passphrase: StrictStr = None + +    class Config: +        schema_extra = { +            "example": { +                "key": "id_key", +                "op": "import_pki", +                "path": ["op", "mode", "path"], +                "passphrase": "passphrase", +            } +        } + +  class ContainerImageModel(ApiModel):      op: StrictStr      name: StrictStr = None @@ -585,6 +601,14 @@ def _configure_op(data: Union[ConfigureModel, ConfigureListModel,      return success(msg) +def create_path_import_pki_no_prompt(path): +    correct_paths = ['ca', 'certificate', 'key-pair'] +    if path[1] not in correct_paths: +        return False +    path[1] = '--' + path[1].replace('-', '') +    path[3] = '--key-filename' +    return path[1:] +  @app.post('/configure')  def configure_op(data: Union[ConfigureModel,                               ConfigureListModel], @@ -814,6 +838,44 @@ def reset_op(data: ResetModel):      return success(res) +@app.post('/import-pki') +def import_pki(data: ImportPkiModel): +    session = app.state.vyos_session + +    op = data.op +    path = data.path + +    lock.acquire() + +    try: +        if op == 'import-pki': +            # need to get rid or interactive mode for private key +            if len(path) == 5 and path[3] in ['key-file', 'private-key']: +                path_no_prompt = create_path_import_pki_no_prompt(path) +                if not path_no_prompt: +                    return error(400, f"Invalid command: {' '.join(path)}") +                if data.passphrase: +                    path_no_prompt += ['--passphrase', data.passphrase] +                res = session.import_pki_no_prompt(path_no_prompt) +            else: +                res = session.import_pki(path) +            if not res[0].isdigit(): +                return error(400, res) +            # commit changes +            session.commit() +            res = res.split('. ')[0] +        else: +            return error(400, f"'{op}' is not a valid operation") +    except ConfigSessionError as e: +        return error(400, str(e)) +    except Exception as e: +        logger.critical(traceback.format_exc()) +        return error(500, "An internal error occured. Check the logs for details.") +    finally: +        lock.release() + +    return success(res) +  @app.post('/poweroff')  def poweroff_op(data: PoweroffModel):      session = app.state.vyos_session | 
