From 0cb8dffb5aed08f605b7bb4d2a153f458759802e Mon Sep 17 00:00:00 2001 From: Tomas Kloda Date: Tue, 24 Mar 2026 16:34:41 +0100 Subject: [PATCH] sync with v0.67.0 --- README.md | 90 ++++++++- defaults/main.yml | 22 +++ plugins/module_utils/netbird_api.py | 111 +++++++++-- plugins/modules/netbird_account.py | 92 ++++++++- plugins/modules/netbird_idp.py | 286 +++++++++++++++++++++++++++ plugins/modules/netbird_info.py | 17 +- plugins/modules/netbird_invite.py | 266 +++++++++++++++++++++++++ plugins/modules/netbird_network.py | 16 +- plugins/modules/netbird_peer.py | 20 +- plugins/modules/netbird_policy.py | 128 +++++++++++- plugins/modules/netbird_setup_key.py | 1 - plugins/modules/netbird_user.py | 33 +++- tasks/identity_providers.yml | 18 ++ tasks/invites.yml | 19 ++ tasks/main.yml | 14 ++ 15 files changed, 1091 insertions(+), 42 deletions(-) create mode 100644 plugins/modules/netbird_idp.py create mode 100644 plugins/modules/netbird_invite.py create mode 100644 tasks/identity_providers.yml create mode 100644 tasks/invites.yml diff --git a/README.md b/README.md index 55455f1..0421b7c 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,10 @@ This collection provides comprehensive management of NetBird resources: - **Routes** - Manage legacy routes (deprecated, use Networks instead) - **DNS** - Configure nameserver groups and DNS settings - **Posture Checks** - Define security compliance requirements -- **Accounts** - Manage account-wide settings +- **Accounts** - Manage account-wide settings (including extra settings, auto-update, peer expose) - **Tokens** - Create and manage personal access tokens +- **Identity Providers** - Configure identity providers (Google, Okta, Entra, OIDC, etc.) +- **Invites** - Manage user invite links with expiration and regeneration - **Info** - Gather information about any resource ## Requirements @@ -189,6 +191,8 @@ Manage NetBird access policies. name: "developers-to-servers" description: "Allow developers SSH access to servers" enabled: true + source_posture_checks: + - "posture-check-id" rules: - name: "ssh-access" sources: @@ -200,6 +204,16 @@ Manage NetBird access policies. ports: - "22" action: "accept" + - name: "web-access" + sources: + - "developers-group-id" + destinations: + - "servers-group-id" + protocol: "tcp" + port_ranges: + - start: 8000 + end: 9000 + action: "accept" state: present ``` @@ -383,6 +397,11 @@ Manage NetBird account settings. jwt_groups_enabled: true jwt_groups_claim_name: "groups" dns_domain: "netbird.example.com" + auto_update_always: true + auto_update_version: "latest" + peer_expose_enabled: true + extra_peer_approval_enabled: false + extra_network_traffic_logs_enabled: true state: present ``` @@ -407,6 +426,72 @@ Manage NetBird personal access tokens. when: new_token.changed ``` +### netbird_idp + +Manage NetBird identity providers. + +```yaml +# Create an OIDC identity provider +- name: Configure identity provider + community.ansible_netbird.netbird_idp: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + name: "corporate-sso" + type: "oidc" + issuer: "https://auth.example.com" + client_id: "your-client-id" + client_secret: "your-client-secret" + state: present + +# Create a Google identity provider +- name: Configure Google IDP + community.ansible_netbird.netbird_idp: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + name: "google-workspace" + type: "google" + issuer: "https://accounts.google.com" + client_id: "your-google-client-id" + client_secret: "your-google-client-secret" + state: present +``` + +### netbird_invite + +Manage NetBird user invites. + +```yaml +# Create a user invite +- name: Invite a new user + community.ansible_netbird.netbird_invite: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + email: "newuser@example.com" + name: "New User" + role: "user" + auto_groups: + - "developers-group-id" + expires_in: 604800 # 7 days + state: present + +# Regenerate an expired invite +- name: Regenerate invite link + community.ansible_netbird.netbird_invite: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + email: "newuser@example.com" + regenerate: true + state: present + +# Delete an invite +- name: Remove invite + community.ansible_netbird.netbird_invite: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + invite_id: "invite-id-123" + state: absent +``` + ### netbird_info Gather information about NetBird resources. @@ -434,7 +519,7 @@ Gather information about NetBird resources. register: me ``` -Available resources: `accounts`, `users`, `peers`, `groups`, `setup_keys`, `policies`, `networks`, `routes`, `dns_nameservers`, `dns_settings`, `posture_checks`, `events`, `countries`, `current_user` +Available resources: `accounts`, `users`, `peers`, `groups`, `setup_keys`, `policies`, `networks`, `routes`, `dns_nameservers`, `dns_settings`, `posture_checks`, `events`, `countries`, `current_user`, `identity_providers`, `invites` ## Role Usage @@ -516,6 +601,7 @@ This collection implements the [NetBird REST API](https://docs.netbird.io/api). - [Networks](https://docs.netbird.io/api/resources/networks) - [DNS](https://docs.netbird.io/api/resources/dns) - [Posture Checks](https://docs.netbird.io/api/resources/posture-checks) +- [Identity Providers](https://docs.netbird.io/api/resources/identity-providers) - [Events](https://docs.netbird.io/api/resources/events) ## Contributing diff --git a/defaults/main.yml b/defaults/main.yml index 58d52f5..0eeec76 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -158,6 +158,28 @@ netbird_posture_checks: [] # min_version: "0.25.0" # state: present +# Identity provider management +netbird_identity_providers: [] +# Example: +# netbird_identity_providers: +# - name: "google-idp" +# type: "google" # entra, google, microsoft, oidc, okta, pocketid, zitadel +# issuer: "https://accounts.google.com" +# client_id: "your-client-id" +# client_secret: "your-client-secret" +# state: present + +# User invite management +netbird_invites: [] +# Example: +# netbird_invites: +# - email: "newuser@example.com" +# name: "New User" +# role: "user" # admin, user +# auto_groups: [] +# expires_in: 604800 # 7 days in seconds +# state: present + # Account settings netbird_account_settings: {} # Example: diff --git a/plugins/module_utils/netbird_api.py b/plugins/module_utils/netbird_api.py index 64f2169..05d754e 100644 --- a/plugins/module_utils/netbird_api.py +++ b/plugins/module_utils/netbird_api.py @@ -244,8 +244,8 @@ class NetBirdAPI: """Get a specific peer.""" return self.get(f'/api/peers/{peer_id}') - def update_peer(self, peer_id, name=None, ssh_enabled=None, login_expiration_enabled=None, - inactivity_expiration_enabled=None, approval_required=None): + def update_peer(self, peer_id, name=None, ssh_enabled=None, login_expiration_enabled=None, + inactivity_expiration_enabled=None, approval_required=None, ip=None): """Update a peer.""" data = {} if name is not None: @@ -258,6 +258,8 @@ class NetBirdAPI: data['inactivity_expiration_enabled'] = inactivity_expiration_enabled if approval_required is not None: data['approval_required'] = approval_required + if ip is not None: + data['ip'] = ip return self.put(f'/api/peers/{peer_id}', data=data) def delete_peer(self, peer_id): @@ -288,11 +290,9 @@ class NetBirdAPI: } return self.post('/api/setup-keys', data=data) - def update_setup_key(self, key_id, name=None, revoked=None, auto_groups=None): - """Update a setup key.""" + def update_setup_key(self, key_id, revoked=None, auto_groups=None): + """Update a setup key. Only revoked and auto_groups can be changed after creation.""" data = {} - if name is not None: - data['name'] = name if revoked is not None: data['revoked'] = revoked if auto_groups is not None: @@ -346,7 +346,7 @@ class NetBirdAPI: """Get a specific policy.""" return self.get(f'/api/policies/{policy_id}') - def create_policy(self, name, enabled=True, description='', rules=None): + def create_policy(self, name, enabled=True, description='', rules=None, source_posture_checks=None): """Create a new policy.""" data = { 'name': name, @@ -354,9 +354,12 @@ class NetBirdAPI: 'description': description, 'rules': rules or [] } + if source_posture_checks is not None: + data['source_posture_checks'] = source_posture_checks return self.post('/api/policies', data=data) - def update_policy(self, policy_id, name=None, enabled=None, description=None, rules=None): + def update_policy(self, policy_id, name=None, enabled=None, description=None, rules=None, + source_posture_checks=None): """Update a policy.""" data = {} if name is not None: @@ -367,6 +370,8 @@ class NetBirdAPI: data['description'] = description if rules is not None: data['rules'] = rules + if source_posture_checks is not None: + data['source_posture_checks'] = source_posture_checks return self.put(f'/api/policies/{policy_id}', data=data) def delete_policy(self, policy_id): @@ -412,11 +417,13 @@ class NetBirdAPI: """Get a specific network router.""" return self.get(f'/api/networks/{network_id}/routers/{router_id}') - def create_network_router(self, network_id, peer_id=None, peer_groups=None, metric=9999, masquerade=False): + def create_network_router(self, network_id, peer_id=None, peer_groups=None, metric=9999, + masquerade=False, enabled=True): """Create a new network router.""" data = { 'metric': metric, - 'masquerade': masquerade + 'masquerade': masquerade, + 'enabled': enabled } if peer_id: data['peer'] = peer_id @@ -424,8 +431,8 @@ class NetBirdAPI: data['peer_groups'] = peer_groups return self.post(f'/api/networks/{network_id}/routers', data=data) - def update_network_router(self, network_id, router_id, peer_id=None, peer_groups=None, - metric=None, masquerade=None): + def update_network_router(self, network_id, router_id, peer_id=None, peer_groups=None, + metric=None, masquerade=None, enabled=None): """Update a network router.""" data = {} if peer_id is not None: @@ -436,6 +443,8 @@ class NetBirdAPI: data['metric'] = metric if masquerade is not None: data['masquerade'] = masquerade + if enabled is not None: + data['enabled'] = enabled return self.put(f'/api/networks/{network_id}/routers/{router_id}', data=data) def delete_network_router(self, network_id, router_id): @@ -647,6 +656,84 @@ class NetBirdAPI: """List all events.""" return self.get('/api/events') + # Identity Provider operations + def list_identity_providers(self): + """List all identity providers.""" + return self.get('/api/identity-providers') + + def get_identity_provider(self, idp_id): + """Get a specific identity provider.""" + return self.get(f'/api/identity-providers/{idp_id}') + + def create_identity_provider(self, name, idp_type, issuer, client_id, client_secret): + """Create a new identity provider.""" + data = { + 'name': name, + 'type': idp_type, + 'issuer': issuer, + 'client_id': client_id, + 'client_secret': client_secret + } + return self.post('/api/identity-providers', data=data) + + def update_identity_provider(self, idp_id, name=None, idp_type=None, issuer=None, + client_id=None, client_secret=None): + """Update an identity provider.""" + data = {} + if name is not None: + data['name'] = name + if idp_type is not None: + data['type'] = idp_type + if issuer is not None: + data['issuer'] = issuer + if client_id is not None: + data['client_id'] = client_id + if client_secret is not None: + data['client_secret'] = client_secret + return self.put(f'/api/identity-providers/{idp_id}', data=data) + + def delete_identity_provider(self, idp_id): + """Delete an identity provider.""" + return self.delete(f'/api/identity-providers/{idp_id}') + + # User Invite operations + def list_user_invites(self): + """List all user invites.""" + return self.get('/api/users/invites') + + def create_user_invite(self, email, name=None, role=None, auto_groups=None, expires_in=None): + """Create a new user invite.""" + data = {'email': email} + if name is not None: + data['name'] = name + if role is not None: + data['role'] = role + if auto_groups is not None: + data['auto_groups'] = auto_groups + if expires_in is not None: + data['expires_in'] = expires_in + return self.post('/api/users/invites', data=data) + + def delete_user_invite(self, invite_id): + """Delete a user invite.""" + return self.delete(f'/api/users/invites/{invite_id}') + + def regenerate_user_invite(self, invite_id, expires_in=None): + """Regenerate a user invite token.""" + data = {} + if expires_in is not None: + data['expires_in'] = expires_in + return self.post(f'/api/users/invites/{invite_id}/regenerate', data=data) + + # User approval operations + def approve_user(self, user_id): + """Approve a pending user.""" + return self.post(f'/api/users/{user_id}/approve') + + def reject_user(self, user_id): + """Reject a pending user.""" + return self.delete(f'/api/users/{user_id}/reject') + # Geo-location operations def list_countries(self): """List all countries.""" diff --git a/plugins/modules/netbird_account.py b/plugins/modules/netbird_account.py index 5100b14..87f3000 100644 --- a/plugins/modules/netbird_account.py +++ b/plugins/modules/netbird_account.py @@ -83,6 +83,53 @@ options: description: - Enable or disable experimental lazy connection. type: bool + extra_peer_approval_enabled: + description: + - Enable or disable peer approval globally. + - When enabled, all peers added will be in pending state until approved by an admin. + type: bool + extra_user_approval_required: + description: + - Enable manual approval for new users joining via domain matching. + - When enabled, users are blocked with pending approval status until approved by an admin. + type: bool + extra_network_traffic_logs_enabled: + description: + - Enable or disable network traffic logging. + - When enabled, all network traffic events from peers will be stored. + type: bool + extra_network_traffic_logs_groups: + description: + - Limits traffic logging to these groups. + - If empty, all peers are enabled. + type: list + elements: str + extra_network_traffic_packet_counter_enabled: + description: + - Enable or disable network traffic packet counter. + - When enabled, network packets and their size will be counted and reported. + type: bool + auto_update_always: + description: + - When true, updates are installed automatically in the background. + - When false, updates require user interaction from the UI. + type: bool + auto_update_version: + description: + - Set clients auto-update version. + - Use "latest", "disabled", or a specific version (e.g., "0.50.1"). + type: str + peer_expose_enabled: + description: + - Enable or disable peer expose. + - When enabled, peers can expose local services through the reverse proxy using the CLI. + type: bool + peer_expose_groups: + description: + - Limits which peer groups are allowed to expose services. + - If empty, all peers are allowed when peer expose is enabled. + type: list + elements: str extends_documentation_fragment: - community.ansible_netbird.netbird requirements: @@ -215,14 +262,44 @@ def build_settings_update(module): value = module.params.get(param) if value is not None: settings[api_field] = value - + + # Build nested extra settings + extra = {} + if module.params.get('extra_peer_approval_enabled') is not None: + extra['peer_approval_enabled'] = module.params['extra_peer_approval_enabled'] + if module.params.get('extra_user_approval_required') is not None: + extra['user_approval_required'] = module.params['extra_user_approval_required'] + if module.params.get('extra_network_traffic_logs_enabled') is not None: + extra['network_traffic_logs_enabled'] = module.params['extra_network_traffic_logs_enabled'] + if module.params.get('extra_network_traffic_logs_groups') is not None: + extra['network_traffic_logs_groups'] = module.params['extra_network_traffic_logs_groups'] + if module.params.get('extra_network_traffic_packet_counter_enabled') is not None: + extra['network_traffic_packet_counter_enabled'] = module.params['extra_network_traffic_packet_counter_enabled'] + if extra: + settings['extra'] = extra + + if module.params.get('auto_update_always') is not None: + settings['auto_update_always'] = module.params['auto_update_always'] + if module.params.get('auto_update_version') is not None: + settings['auto_update_version'] = module.params['auto_update_version'] + if module.params.get('peer_expose_enabled') is not None: + settings['peer_expose_enabled'] = module.params['peer_expose_enabled'] + if module.params.get('peer_expose_groups') is not None: + settings['peer_expose_groups'] = module.params['peer_expose_groups'] + return settings def settings_need_update(current_settings, desired_settings): """Check if account settings need to be updated.""" for key, value in desired_settings.items(): - if current_settings.get(key) != value: + if key == 'extra': + # Compare nested extra settings + current_extra = current_settings.get('extra', {}) + for extra_key, extra_value in value.items(): + if current_extra.get(extra_key) != extra_value: + return True + elif current_settings.get(key) != value: return True return False @@ -245,7 +322,16 @@ def run_module(): routing_peer_dns_resolution_enabled=dict(type='bool'), dns_domain=dict(type='str'), network_range=dict(type='str'), - lazy_connection_enabled=dict(type='bool') + lazy_connection_enabled=dict(type='bool'), + extra_peer_approval_enabled=dict(type='bool'), + extra_user_approval_required=dict(type='bool'), + extra_network_traffic_logs_enabled=dict(type='bool'), + extra_network_traffic_logs_groups=dict(type='list', elements='str'), + extra_network_traffic_packet_counter_enabled=dict(type='bool'), + auto_update_always=dict(type='bool'), + auto_update_version=dict(type='str'), + peer_expose_enabled=dict(type='bool'), + peer_expose_groups=dict(type='list', elements='str') ) module = AnsibleModule( diff --git a/plugins/modules/netbird_idp.py b/plugins/modules/netbird_idp.py new file mode 100644 index 0000000..750445c --- /dev/null +++ b/plugins/modules/netbird_idp.py @@ -0,0 +1,286 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2024, Community +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Ansible module for managing NetBird identity providers.""" + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: netbird_idp +short_description: Manage NetBird identity providers +description: + - Create, update, and delete identity providers in NetBird. + - Identity providers are used to authenticate users via OIDC-compatible providers. +version_added: "1.0.0" +author: + - Community +options: + state: + description: + - The desired state of the identity provider. + type: str + choices: ['present', 'absent'] + default: present + idp_id: + description: + - The unique identifier of the identity provider. + - Used for update or delete by ID. + type: str + name: + description: + - Name of the identity provider. + - Required when state is present. + - Used for lookup and creation. + type: str + type: + description: + - The type of identity provider. + - Required when creating a new identity provider. + type: str + choices: ['entra', 'google', 'microsoft', 'oidc', 'okta', 'pocketid', 'zitadel'] + issuer: + description: + - The OIDC issuer URL for the identity provider. + - Required when creating a new identity provider. + type: str + client_id: + description: + - The OIDC client ID for the identity provider. + - Required when creating a new identity provider. + type: str + client_secret: + description: + - The OIDC client secret for the identity provider. + - Required when creating a new identity provider. + - This value is write-only and never returned by the API. + type: str + no_log: true +extends_documentation_fragment: + - community.ansible_netbird.netbird +requirements: + - python >= 3.6 +''' + +EXAMPLES = r''' +- name: Create an identity provider + community.ansible_netbird.netbird_idp: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + name: "corporate-okta" + type: "okta" + issuer: "https://dev-123456.okta.com" + client_id: "0oa1b2c3d4e5f6g7h8i9" + client_secret: "{{ okta_client_secret }}" + state: present + +- name: Update an identity provider by name + community.ansible_netbird.netbird_idp: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + name: "corporate-okta" + type: "okta" + issuer: "https://dev-789012.okta.com" + client_id: "0oa1b2c3d4e5f6g7h8i9" + client_secret: "{{ okta_client_secret }}" + state: present + +- name: Delete an identity provider by ID + community.ansible_netbird.netbird_idp: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + idp_id: "idp-id-123" + state: absent + +- name: Delete an identity provider by name + community.ansible_netbird.netbird_idp: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + name: "corporate-okta" + state: absent +''' + +RETURN = r''' +identity_provider: + description: The identity provider object. + returned: success + type: dict + contains: + id: + description: Identity provider ID. + type: str + name: + description: Identity provider name. + type: str + type: + description: Identity provider type. + type: str + issuer: + description: OIDC issuer URL. + type: str + client_id: + description: OIDC client ID. + type: str +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.ansible_netbird.plugins.module_utils.netbird_api import ( + NetBirdAPI, + NetBirdAPIError, + netbird_argument_spec +) + + +def find_idp_by_name(api, name): + """Find an identity provider by name.""" + idps, _ = api.list_identity_providers() + for idp in idps: + if idp.get('name') == name: + return idp + return None + + +def idp_needs_update(current, desired): + """Check if identity provider needs to be updated.""" + if 'name' in desired and desired['name'] is not None: + if current.get('name') != desired['name']: + return True + + if 'type' in desired and desired['type'] is not None: + if current.get('type') != desired['type']: + return True + + if 'issuer' in desired and desired['issuer'] is not None: + if current.get('issuer') != desired['issuer']: + return True + + if 'client_id' in desired and desired['client_id'] is not None: + if current.get('client_id') != desired['client_id']: + return True + + # Skip client_secret comparison since it's write-only/never returned by the API + + return False + + +def run_module(): + """Main module execution.""" + argument_spec = netbird_argument_spec() + argument_spec.update( + state=dict(type='str', choices=['present', 'absent'], default='present'), + idp_id=dict(type='str'), + name=dict(type='str'), + type=dict(type='str', choices=['entra', 'google', 'microsoft', 'oidc', 'okta', 'pocketid', 'zitadel']), + issuer=dict(type='str'), + client_id=dict(type='str'), + client_secret=dict(type='str', no_log=True) + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ('state', 'present', ['name'], True), + ], + required_one_of=[ + ('idp_id', 'name'), + ] + ) + + api = NetBirdAPI( + module, + module.params['api_url'], + module.params['api_token'], + module.params['validate_certs'] + ) + + state = module.params['state'] + idp_id = module.params['idp_id'] + name = module.params['name'] + idp_type = module.params['type'] + issuer = module.params['issuer'] + client_id = module.params['client_id'] + client_secret = module.params['client_secret'] + + result = dict( + changed=False, + identity_provider={} + ) + + try: + # Find existing identity provider + existing_idp = None + if idp_id: + try: + existing_idp, _ = api.get_identity_provider(idp_id) + except NetBirdAPIError as e: + if e.status_code != 404: + raise + elif name: + existing_idp = find_idp_by_name(api, name) + + if state == 'absent': + if existing_idp: + if not module.check_mode: + api.delete_identity_provider(existing_idp['id']) + result['changed'] = True + result['msg'] = 'Identity provider deleted successfully' + module.exit_json(**result) + + # state == 'present' + if existing_idp: + # Check if update is needed + desired = { + 'name': name, + 'type': idp_type, + 'issuer': issuer, + 'client_id': client_id + } + + if idp_needs_update(existing_idp, desired): + if not module.check_mode: + idp, _ = api.update_identity_provider( + existing_idp['id'], + name=name, + idp_type=idp_type, + issuer=issuer, + client_id=client_id, + client_secret=client_secret + ) + result['identity_provider'] = idp + else: + result['identity_provider'] = existing_idp + result['changed'] = True + else: + result['identity_provider'] = existing_idp + else: + # Create new identity provider + if not name: + module.fail_json(msg="name is required when creating a new identity provider") + + if not module.check_mode: + idp, _ = api.create_identity_provider( + name=name, + idp_type=idp_type, + issuer=issuer, + client_id=client_id, + client_secret=client_secret + ) + result['identity_provider'] = idp + result['changed'] = True + + module.exit_json(**result) + + except NetBirdAPIError as e: + module.fail_json(msg=str(e), status_code=e.status_code, response=e.response) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/netbird_info.py b/plugins/modules/netbird_info.py index b162024..2cfe1cc 100644 --- a/plugins/modules/netbird_info.py +++ b/plugins/modules/netbird_info.py @@ -23,9 +23,10 @@ options: description: - Type of resource to gather information about. type: str - choices: ['accounts', 'users', 'peers', 'groups', 'setup_keys', 'policies', - 'networks', 'routes', 'dns_nameservers', 'dns_settings', - 'posture_checks', 'events', 'countries', 'current_user'] + choices: ['accounts', 'users', 'peers', 'groups', 'setup_keys', 'policies', + 'networks', 'routes', 'dns_nameservers', 'dns_settings', + 'posture_checks', 'events', 'countries', 'current_user', + 'identity_providers', 'invites'] required: true service_user: description: @@ -133,10 +134,10 @@ def run_module(): resource=dict( type='str', required=True, - choices=['accounts', 'users', 'peers', 'groups', 'setup_keys', - 'policies', 'networks', 'routes', 'dns_nameservers', + choices=['accounts', 'users', 'peers', 'groups', 'setup_keys', + 'policies', 'networks', 'routes', 'dns_nameservers', 'dns_settings', 'posture_checks', 'events', 'countries', - 'current_user'] + 'current_user', 'identity_providers', 'invites'] ), service_user=dict(type='bool'), country_code=dict(type='str') @@ -192,6 +193,10 @@ def run_module(): data, _ = api.list_events() elif resource == 'countries': data, _ = api.list_countries() + elif resource == 'identity_providers': + data, _ = api.list_identity_providers() + elif resource == 'invites': + data, _ = api.list_user_invites() else: module.fail_json(msg=f"Unknown resource type: {resource}") diff --git a/plugins/modules/netbird_invite.py b/plugins/modules/netbird_invite.py new file mode 100644 index 0000000..a22036c --- /dev/null +++ b/plugins/modules/netbird_invite.py @@ -0,0 +1,266 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2024, Community +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""Ansible module for managing NetBird user invites.""" + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: netbird_invite +short_description: Manage NetBird user invites +description: + - Create, delete, and regenerate user invites in NetBird. + - Invites are used to onboard new users to a NetBird account. + - Invites cannot be updated, only created, deleted, or regenerated. +version_added: "1.0.0" +author: + - Community +options: + state: + description: + - The desired state of the user invite. + type: str + choices: ['present', 'absent'] + default: present + invite_id: + description: + - The unique identifier of the user invite. + - Can be used to delete a specific invite when state is absent. + type: str + email: + description: + - Email address for the invite. + - Required when state is present. + type: str + name: + description: + - Name of the invited user. + type: str + role: + description: + - Role to assign to the invited user. + type: str + choices: ['admin', 'user'] + default: user + auto_groups: + description: + - List of group IDs to auto-assign to the invited user. + type: list + elements: str + default: [] + expires_in: + description: + - Expiration time for the invite in seconds. + type: int + regenerate: + description: + - If true and an invite already exists for the email, regenerate the invite token. + type: bool + default: false +extends_documentation_fragment: + - community.ansible_netbird.netbird +requirements: + - python >= 3.6 +''' + +EXAMPLES = r''' +- name: Create a user invite + community.ansible_netbird.netbird_invite: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + email: "newuser@example.com" + name: "New User" + role: "user" + auto_groups: + - "group-id-1" + expires_in: 604800 + state: present + +- name: Create an admin invite + community.ansible_netbird.netbird_invite: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + email: "admin@example.com" + name: "Admin User" + role: "admin" + state: present + +- name: Regenerate an existing invite + community.ansible_netbird.netbird_invite: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + email: "newuser@example.com" + regenerate: true + state: present + +- name: Delete an invite by email + community.ansible_netbird.netbird_invite: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + email: "newuser@example.com" + state: absent + +- name: Delete an invite by ID + community.ansible_netbird.netbird_invite: + api_url: "https://netbird.example.com" + api_token: "{{ netbird_token }}" + invite_id: "invite-id-123" + state: absent +''' + +RETURN = r''' +invite: + description: The user invite object. + returned: success + type: dict + contains: + id: + description: Invite ID. + type: str + email: + description: Invited user email. + type: str + name: + description: Invited user name. + type: str + role: + description: Assigned role. + type: str + auto_groups: + description: Auto-assigned group IDs. + type: list + expires_at: + description: Invite expiration timestamp. + type: str + created_at: + description: Invite creation timestamp. + type: str + expired: + description: Whether the invite has expired. + type: bool +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.ansible_netbird.plugins.module_utils.netbird_api import ( + NetBirdAPI, + NetBirdAPIError, + netbird_argument_spec +) + + +def find_invite_by_email(api, email): + """Find a user invite by email address.""" + invites, _ = api.list_user_invites() + for invite in invites: + if invite.get('email') == email: + return invite + return None + + +def run_module(): + """Main module execution.""" + argument_spec = netbird_argument_spec() + argument_spec.update( + state=dict(type='str', choices=['present', 'absent'], default='present'), + invite_id=dict(type='str'), + email=dict(type='str'), + name=dict(type='str'), + role=dict(type='str', choices=['admin', 'user'], default='user'), + auto_groups=dict(type='list', elements='str', default=[]), + expires_in=dict(type='int'), + regenerate=dict(type='bool', default=False) + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ('state', 'present', ['email']), + ] + ) + + api = NetBirdAPI( + module, + module.params['api_url'], + module.params['api_token'], + module.params['validate_certs'] + ) + + state = module.params['state'] + invite_id = module.params['invite_id'] + email = module.params['email'] + name = module.params['name'] + role = module.params['role'] + auto_groups = module.params['auto_groups'] + expires_in = module.params['expires_in'] + regenerate = module.params['regenerate'] + + result = dict( + changed=False, + invite={} + ) + + try: + if state == 'absent': + if invite_id: + if not module.check_mode: + api.delete_user_invite(invite_id) + result['changed'] = True + result['msg'] = 'User invite deleted successfully' + elif email: + existing_invite = find_invite_by_email(api, email) + if existing_invite: + if not module.check_mode: + api.delete_user_invite(existing_invite['id']) + result['changed'] = True + result['msg'] = 'User invite deleted successfully' + module.exit_json(**result) + + # state == 'present' + existing_invite = find_invite_by_email(api, email) + + if existing_invite: + if regenerate: + # Regenerate the invite token + if not module.check_mode: + invite, _ = api.regenerate_user_invite( + existing_invite['id'], + expires_in=expires_in + ) + result['invite'] = invite + else: + result['invite'] = existing_invite + result['changed'] = True + else: + # Invite already exists, return it + result['invite'] = existing_invite + result['msg'] = 'User invite already exists' + else: + # Create new invite + if not module.check_mode: + invite, _ = api.create_user_invite( + email=email, + name=name, + role=role, + auto_groups=auto_groups, + expires_in=expires_in + ) + result['invite'] = invite + result['changed'] = True + + module.exit_json(**result) + + except NetBirdAPIError as e: + module.fail_json(msg=str(e), status_code=e.status_code, response=e.response) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/netbird_network.py b/plugins/modules/netbird_network.py index e3625ee..ea6bd48 100644 --- a/plugins/modules/netbird_network.py +++ b/plugins/modules/netbird_network.py @@ -72,6 +72,11 @@ options: - Whether to masquerade (NAT) traffic through this router. type: bool default: false + enabled: + description: + - Whether the router is enabled. + type: bool + default: true resources: description: - List of resources (network addresses, CIDRs, or domains) for this network. @@ -348,6 +353,8 @@ def router_needs_update(current, desired): return True if current.get('masquerade') != desired.get('masquerade', False): return True + if current.get('enabled') != desired.get('enabled', True): + return True return False @@ -399,7 +406,8 @@ def sync_routers(api, module, network_id, desired_routers): peer_id=peer if peer else None, peer_groups=peer_groups, metric=desired.get('metric', 9999), - masquerade=desired.get('masquerade', False) + masquerade=desired.get('masquerade', False), + enabled=desired.get('enabled', True) ) final_routers.append(updated) else: @@ -415,7 +423,8 @@ def sync_routers(api, module, network_id, desired_routers): peer_id=peer if peer else None, peer_groups=peer_groups, metric=desired.get('metric', 9999), - masquerade=desired.get('masquerade', False) + masquerade=desired.get('masquerade', False), + enabled=desired.get('enabled', True) ) final_routers.append(created) changed = True @@ -503,7 +512,8 @@ def run_module(): peer=dict(type='str'), peer_groups=dict(type='list', elements='str'), metric=dict(type='int', default=9999), - masquerade=dict(type='bool', default=False) + masquerade=dict(type='bool', default=False), + enabled=dict(type='bool', default=True) ), required_one_of=[('peer', 'peer_groups')], mutually_exclusive=[('peer', 'peer_groups')] diff --git a/plugins/modules/netbird_peer.py b/plugins/modules/netbird_peer.py index 3f9ba5c..9de098a 100644 --- a/plugins/modules/netbird_peer.py +++ b/plugins/modules/netbird_peer.py @@ -52,6 +52,11 @@ options: description: - Whether approval is required for the peer. type: bool + ip: + description: + - The IP address to assign to the peer within the NetBird network. + - Allows reassigning a peer's IP. + type: str extends_documentation_fragment: - community.ansible_netbird.netbird requirements: @@ -167,8 +172,8 @@ from ansible_collections.community.ansible_netbird.plugins.module_utils.netbird_ def peer_needs_update(current, params): """Check if peer needs to be updated.""" - for key in ['name', 'ssh_enabled', 'login_expiration_enabled', - 'inactivity_expiration_enabled', 'approval_required']: + for key in ['name', 'ssh_enabled', 'login_expiration_enabled', + 'inactivity_expiration_enabled', 'approval_required', 'ip']: if params.get(key) is not None: if current.get(key) != params[key]: return True @@ -185,7 +190,8 @@ def run_module(): ssh_enabled=dict(type='bool'), login_expiration_enabled=dict(type='bool'), inactivity_expiration_enabled=dict(type='bool'), - approval_required=dict(type='bool') + approval_required=dict(type='bool'), + ip=dict(type='str') ) module = AnsibleModule( @@ -232,9 +238,10 @@ def run_module(): 'ssh_enabled': module.params['ssh_enabled'], 'login_expiration_enabled': module.params['login_expiration_enabled'], 'inactivity_expiration_enabled': module.params['inactivity_expiration_enabled'], - 'approval_required': module.params['approval_required'] + 'approval_required': module.params['approval_required'], + 'ip': module.params['ip'] } - + if peer_needs_update(existing_peer, update_params): if not module.check_mode: peer, _ = api.update_peer( @@ -243,7 +250,8 @@ def run_module(): ssh_enabled=module.params['ssh_enabled'], login_expiration_enabled=module.params['login_expiration_enabled'], inactivity_expiration_enabled=module.params['inactivity_expiration_enabled'], - approval_required=module.params['approval_required'] + approval_required=module.params['approval_required'], + ip=module.params['ip'] ) result['peer'] = peer else: diff --git a/plugins/modules/netbird_policy.py b/plugins/modules/netbird_policy.py index e541e7b..945f655 100644 --- a/plugins/modules/netbird_policy.py +++ b/plugins/modules/netbird_policy.py @@ -45,6 +45,11 @@ options: - Whether the policy is enabled. type: bool default: true + source_posture_checks: + description: + - List of posture check IDs applied to policy source groups. + type: list + elements: str rules: description: - List of policy rules. @@ -82,7 +87,7 @@ options: default: true protocol: description: - - Network protocol (all, tcp, udp, icmp). + - Network protocol (all, tcp, udp, icmp, netbird-ssh). type: str default: all ports: @@ -90,6 +95,53 @@ options: - List of destination ports (e.g., ["80", "443", "8000-9000"]). type: list elements: str + port_ranges: + description: + - List of port ranges. + - Each range has start and end port numbers. + type: list + elements: dict + suboptions: + start: + description: + - Start port number. + type: int + required: true + end: + description: + - End port number. + type: int + required: true + destination_resource: + description: + - Destination network resource for the rule. + type: dict + suboptions: + id: + description: + - Resource ID. + type: str + required: true + type: + description: + - Resource type. + type: str + required: true + source_resource: + description: + - Source network resource for the rule. + type: dict + suboptions: + id: + description: + - Resource ID. + type: str + required: true + type: + description: + - Resource type. + type: str + required: true action: description: - Action to take (accept, drop). @@ -194,6 +246,43 @@ def find_policy_by_name(api, name): return None +def build_rule_data(rule): + """Build rule payload for the API from Ansible rule config.""" + rule_data = {} + if rule.get('name') is not None: + rule_data['name'] = rule['name'] + if rule.get('description') is not None: + rule_data['description'] = rule['description'] + if rule.get('enabled') is not None: + rule_data['enabled'] = rule['enabled'] + if rule.get('sources') is not None: + rule_data['sources'] = rule['sources'] + if rule.get('destinations') is not None: + rule_data['destinations'] = rule['destinations'] + if rule.get('bidirectional') is not None: + rule_data['bidirectional'] = rule['bidirectional'] + if rule.get('protocol') is not None: + rule_data['protocol'] = rule['protocol'] + if rule.get('ports') is not None: + rule_data['ports'] = rule['ports'] + if rule.get('port_ranges') is not None: + rule_data['port_ranges'] = rule['port_ranges'] + if rule.get('destination_resource') is not None: + rule_data['destinationResource'] = rule['destination_resource'] + if rule.get('source_resource') is not None: + rule_data['sourceResource'] = rule['source_resource'] + if rule.get('action') is not None: + rule_data['action'] = rule['action'] + return rule_data + + +def build_rules_data(rules): + """Build list of rule payloads for the API.""" + if rules is None: + return None + return [build_rule_data(rule) for rule in rules] + + def policy_needs_update(current, params): """Check if policy needs to be updated.""" if params.get('name') is not None and current.get('name') != params['name']: @@ -202,6 +291,8 @@ def policy_needs_update(current, params): return True if params.get('enabled') is not None and current.get('enabled') != params['enabled']: return True + if params.get('source_posture_checks') is not None and current.get('source_posture_checks') != params['source_posture_checks']: + return True # For rules, always update if provided to ensure they match exactly if params.get('rules') is not None: return True @@ -217,7 +308,30 @@ def run_module(): name=dict(type='str'), description=dict(type='str', default=''), enabled=dict(type='bool', default=True), - rules=dict(type='list', elements='dict') + source_posture_checks=dict(type='list', elements='str'), + rules=dict(type='list', elements='dict', options=dict( + name=dict(type='str'), + description=dict(type='str'), + enabled=dict(type='bool', default=True), + sources=dict(type='list', elements='str'), + destinations=dict(type='list', elements='str'), + bidirectional=dict(type='bool', default=True), + protocol=dict(type='str', default='all'), + ports=dict(type='list', elements='str'), + port_ranges=dict(type='list', elements='dict', options=dict( + start=dict(type='int', required=True), + end=dict(type='int', required=True) + )), + destination_resource=dict(type='dict', options=dict( + id=dict(type='str', required=True), + type=dict(type='str', required=True) + )), + source_resource=dict(type='dict', options=dict( + id=dict(type='str', required=True), + type=dict(type='str', required=True) + )), + action=dict(type='str', default='accept') + )) ) module = AnsibleModule( @@ -240,7 +354,8 @@ def run_module(): name = module.params['name'] description = module.params['description'] enabled = module.params['enabled'] - rules = module.params['rules'] + source_posture_checks = module.params['source_posture_checks'] + rules = build_rules_data(module.params['rules']) result = dict( changed=False, @@ -274,6 +389,7 @@ def run_module(): 'name': name, 'description': description, 'enabled': enabled, + 'source_posture_checks': source_posture_checks, 'rules': rules } @@ -284,7 +400,8 @@ def run_module(): name=name, enabled=enabled, description=description, - rules=rules + rules=rules, + source_posture_checks=source_posture_checks ) result['policy'] = policy else: @@ -302,7 +419,8 @@ def run_module(): name=name, enabled=enabled, description=description, - rules=rules or [] + rules=rules or [], + source_posture_checks=source_posture_checks ) result['policy'] = policy result['changed'] = True diff --git a/plugins/modules/netbird_setup_key.py b/plugins/modules/netbird_setup_key.py index 6788d7c..909407b 100644 --- a/plugins/modules/netbird_setup_key.py +++ b/plugins/modules/netbird_setup_key.py @@ -278,7 +278,6 @@ def run_module(): if not module.check_mode: key, _ = api.update_setup_key( existing_key['id'], - name=name, revoked=module.params['revoked'], auto_groups=module.params['auto_groups'] ) diff --git a/plugins/modules/netbird_user.py b/plugins/modules/netbird_user.py index 1796def..2000b6a 100644 --- a/plugins/modules/netbird_user.py +++ b/plugins/modules/netbird_user.py @@ -24,7 +24,6 @@ options: - The desired state of the user. type: str choices: ['present', 'absent'] - default: present user_id: description: - The unique identifier of the user. @@ -60,6 +59,14 @@ options: description: - If set to true, the user is blocked and cannot use the system. type: bool + action: + description: + - Action to perform on the user. + - Use 'approve' to approve a user with pending approval status. + - Use 'reject' to reject a user with pending approval status. + - Mutually exclusive with state. + type: str + choices: ['approve', 'reject'] resend_invitation: description: - Resend user invitation email. @@ -193,7 +200,7 @@ def run_module(): """Main module execution.""" argument_spec = netbird_argument_spec() argument_spec.update( - state=dict(type='str', choices=['present', 'absent'], default='present'), + state=dict(type='str', choices=['present', 'absent']), user_id=dict(type='str'), email=dict(type='str'), name=dict(type='str'), @@ -201,7 +208,8 @@ def run_module(): auto_groups=dict(type='list', elements='str', default=[]), is_service_user=dict(type='bool', default=False), is_blocked=dict(type='bool'), - resend_invitation=dict(type='bool', default=False) + resend_invitation=dict(type='bool', default=False), + action=dict(type='str', choices=['approve', 'reject']), ) module = AnsibleModule( @@ -209,7 +217,9 @@ def run_module(): supports_check_mode=True, required_if=[ ('state', 'absent', ['user_id']), - ] + ], + mutually_exclusive=[['state', 'action']], + required_one_of=[['state', 'action']] ) api = NetBirdAPI( @@ -235,6 +245,21 @@ def run_module(): ) try: + # Handle action parameter (approve/reject) + action = module.params.get('action') + if action: + user_id = module.params['user_id'] + if not user_id: + module.fail_json(msg="user_id is required when using action parameter") + if not module.check_mode: + if action == 'approve': + api.approve_user(user_id) + elif action == 'reject': + api.reject_user(user_id) + result['changed'] = True + result['msg'] = f'User {action}d successfully' + module.exit_json(**result) + # Handle resend invitation if resend_invitation and user_id: if not module.check_mode: diff --git a/tasks/identity_providers.yml b/tasks/identity_providers.yml new file mode 100644 index 0000000..db68fdf --- /dev/null +++ b/tasks/identity_providers.yml @@ -0,0 +1,18 @@ +--- +# tasks/identity_providers.yml - Identity provider management tasks + +- name: Manage NetBird identity providers + community.ansible_netbird.netbird_idp: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + validate_certs: "{{ netbird_validate_certs }}" + name: "{{ item.name }}" + type: "{{ item.type }}" + issuer: "{{ item.issuer }}" + client_id: "{{ item.client_id }}" + client_secret: "{{ item.client_secret }}" + state: "{{ item.state | default('present') }}" + loop: "{{ netbird_identity_providers }}" + loop_control: + label: "{{ item.name }}" + when: netbird_identity_providers | length > 0 diff --git a/tasks/invites.yml b/tasks/invites.yml new file mode 100644 index 0000000..29decf6 --- /dev/null +++ b/tasks/invites.yml @@ -0,0 +1,19 @@ +--- +# tasks/invites.yml - User invite management tasks + +- name: Manage NetBird user invites + community.ansible_netbird.netbird_invite: + api_url: "{{ netbird_api_url }}" + api_token: "{{ netbird_api_token }}" + validate_certs: "{{ netbird_validate_certs }}" + email: "{{ item.email }}" + name: "{{ item.name | default(omit) }}" + role: "{{ item.role | default('user') }}" + auto_groups: "{{ item.auto_groups | default([]) }}" + expires_in: "{{ item.expires_in | default(omit) }}" + regenerate: "{{ item.regenerate | default(false) }}" + state: "{{ item.state | default('present') }}" + loop: "{{ netbird_invites }}" + loop_control: + label: "{{ item.email }}" + when: netbird_invites | length > 0 diff --git a/tasks/main.yml b/tasks/main.yml index 3627fe4..7932128 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -64,3 +64,17 @@ - netbird - netbird-posture-checks +- name: Include identity provider management tasks + ansible.builtin.include_tasks: identity_providers.yml + when: netbird_identity_providers | length > 0 + tags: + - netbird + - netbird-identity-providers + +- name: Include user invite management tasks + ansible.builtin.include_tasks: invites.yml + when: netbird_invites | length > 0 + tags: + - netbird + - netbird-invites +