diff --git a/README.md b/README.md index 73f6a9e..d1f1f5a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ If you need help setting up a custom integration, you can create an [issue](http - [Kandji](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kandji/) - [Lima Charlie](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/lima-charlie/) - [Manage Engine Endpoint Central](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/manage-engine-endpoint-central/) +- [Moysle](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/moysle/) - [Netskope](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/netskope/) - [NinjaOne](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ninjaone/) - [Proxmox](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/proxmox/) diff --git a/docs/integrations.json b/docs/integrations.json index ce74e24..abab71b 100644 --- a/docs/integrations.json +++ b/docs/integrations.json @@ -1,7 +1,13 @@ { - "lastUpdated": "2025-12-11T19:44:50.765475Z", - "totalIntegrations": 29, + "lastUpdated": "2025-12-15T22:06:49.938502Z", + "totalIntegrations": 30, "integrationDetails": [ + { + "name": "Moysle", + "type": "inbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/moysle/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/moysle/custom-integration-moysle.star" + }, { "name": "Lima Charlie", "type": "inbound", diff --git a/moysle/README.md b/moysle/README.md new file mode 100644 index 0000000..34cc20e --- /dev/null +++ b/moysle/README.md @@ -0,0 +1,74 @@ +# Custom Integration: Moysle + +## runZero Requirements + +- Superuser access to the [Custom Integrations configuration](https://console.runzero.com/custom-integrations) in runZero. + +## Moysle Requirements + +- Moysle API token (`access_key`). +- Moysle admin account email and password, provided in `access_secret` as a JSON/dict (`{"email": "", "password": ""}` or `{"username": "", "password": ""}`). +- Account must have permission to access device inventory. + +## Steps + +### Moysle Configuration + +1. Gather your Moysle API credentials: + - Obtain your **API token** from the Moysle admin portal. + - Use a valid Moysle admin email and password. The script performs the login and bearer retrieval for you. + +2. Test your credentials (optional but recommended): + - Use a tool like Postman or curl to confirm login is working. + - Example request (bearer is returned in the `Authorization` response header; the script handles this automatically): + ```bash + curl -i -X POST "https://managerapi.mosyle.com/v2/login" \ + -H "Content-Type: application/json" \ + -d '{ + "accessToken": "", + "email": "", + "password": "" + }' + ``` + +3. Verify device access: + - Use the bearer token returned above and include the access token in the request body (the script loops per-OS over `ios`, `mac`, `tvos`, `visionos`): + ```bash + curl -X POST "https://managerapi.mosyle.com/v2/listdevices" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "accessToken": "", + "options": { + "os": "all", + "page": 0 + } + }' + ``` + +### runZero Configuration + +1. (OPTIONAL) - Modify the Starlark script to match your desired filtering, pagination, or attribute mapping. + +2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). + - Select the type `Custom Integration Script Secrets`. + - Use the `access_key` field for your API token. + - Use the `access_secret` field as JSON/dict with keys `{"email": "", "password": ""}` (or `username`). Pre-issued bearer tokens are not used by the script. + +3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). + - Add a Name and Icon for the integration (e.g., `moysle`). + - Toggle `Enable custom integration script` to paste in the finalized script. + - Click `Validate` to ensure it has valid syntax. + - Click `Save` to create the Custom Integration. + +4. [Create the Custom Integration task](https://console.runzero.com/ingest/custom/). + - Select the Credential and Custom Integration created in steps 2 and 3. + - Set the task schedule to run as needed. + - Select the hosted Explorer to run the integration from. + - Click `Save` to activate the task. + +### What's Next? + +- The task will appear on the [Tasks](https://console.runzero.com/tasks) page and run like any other integration. +- It will update existing assets or create new ones based on device merge criteria (hostname, MAC, etc.). +- You can filter assets imported via this integration using:`custom_integration:moysle` diff --git a/moysle/config.json b/moysle/config.json new file mode 100644 index 0000000..07ab234 --- /dev/null +++ b/moysle/config.json @@ -0,0 +1 @@ +{ "name": "Moysle", "type": "inbound" } diff --git a/moysle/custom-integration-moysle.star b/moysle/custom-integration-moysle.star new file mode 100644 index 0000000..3567077 --- /dev/null +++ b/moysle/custom-integration-moysle.star @@ -0,0 +1,242 @@ +load('requests', 'Session') +load('json', json_encode='encode', json_decode='decode') +load('runzero.types', 'ImportAsset', 'NetworkInterface') +load('net', 'ip_address') +load('flatten_json', 'flatten') + +BASE_URL = "https://managerapi.mosyle.com/v2" + + +def parse_credentials(secret): + """ + Parse access_secret provided as a dict or JSON string containing email/username and password. + """ + if not secret: + return None, None + + creds = secret + if type(secret) == "string": + if secret.find("{") != -1: + creds = json_decode(secret) + else: + print("access_secret must be a JSON string with email/password") + return None, None + + if type(creds) == "dict": + email = creds.get("email") or creds.get("username") + password = creds.get("password") + if email and password: + return email, password + else: + print("Missing email or password in access_secret") + return None, None + + return None, None + + +def get_bearer_token(session, access_token, email, password): + """ + Perform JWT login and return the bearer token from the Authorization header. + """ + login_url = "{}/login".format(BASE_URL) + payload = { + "accessToken": access_token, + "email": email, + "password": password, + } + resp = session.post(login_url, body=bytes(json_encode(payload))) + if not resp or resp.status_code != 200: + print("Login failed: {}".format(resp.status_code if resp else "no response")) + return None + auth_header = None + if resp.headers: + if "Authorization" in resp.headers: + auth_header = resp.headers.get("Authorization", None) + elif "authorization" in resp.headers: + auth_header = resp.headers.get("authorization", None) + + if auth_header: + print("Login succeeded with bearer token") + return auth_header[0].split(" ")[1] + else: + print("Login succeeded but bearer token missing from headers") + return None + + +def build_network_interface(mac, ips): + """ + Build a NetworkInterface from a MAC and list of IP strings. + """ + ip4s = [] + ip6s = [] + for ip in ips: + if not ip: + continue + # IPv6 has a %interface appended + ip = ip.split("%")[0] + addr = ip_address(ip) + if addr.version == 4: + ip4s.append(addr) + elif addr.version == 6: + ip6s.append(addr) + + if not mac and not ip4s and not ip6s: + return None + + return NetworkInterface(macAddress=mac or None, ipv4Addresses=ip4s, ipv6Addresses=ip6s) + + +def collect_hostnames(device): + names = [] + for key in ["device_name", "devicename", "HostName", "LocalHostName", "hostname"]: + name = device.get(key, "") + if name and name not in names: + safe_name = name.replace(" ", "-") + names.append(safe_name) + return names + + +def parse_tags(raw_tags, asset_tag): + tags = [] + if raw_tags and type(raw_tags) == "string": + for chunk in raw_tags.split(","): + for part in chunk.split(): + part = part.strip() + if part and part not in tags: + tags.append(part) + if asset_tag and asset_tag not in tags: + tags.append(asset_tag) + return tags if tags else None + + +def build_custom_attributes(device, used_keys): + flat = flatten(device) + attrs = {} + for key in flat: + if key in used_keys: + continue + value = flat.get(key) + if value == None: + continue + attrs[key] = "{}".format(value) + return attrs if attrs else None + + +def main(*args, **kwargs): + """ + Custom integration for importing Mosyle device inventory into runZero. + Requires access_key (API token) and access_secret (JSON or dict with email/username and password). + """ + api_token = kwargs.get("access_key") + email, password = parse_credentials(kwargs.get("access_secret")) + if not api_token or not email or not password: + print("Missing required credentials") + return [] + + session = Session() + session.headers.set("Content-Type", "application/json") + session.headers.set("Accept", "application/json") + session.headers.set("User-Agent", "runZeroCustomScript/1.0") + + bearer = get_bearer_token(session, api_token, email, password) + if not bearer: + return [] + + session.headers.set("Authorization", "Bearer {}".format(bearer)) + + assets = [] + + for os_type in ["ios", "mac", "tvos", "visionos"]: + print("Fetching {} devices".format(os_type)) + page = 0 + + while True: + list_url = "{}/listdevices".format(BASE_URL) + list_payload = { + "accessToken": api_token, + "options": { + "os": os_type, + "page": page, + }, + } + + device_resp = session.post(list_url, body=bytes(json_encode(list_payload))) + if not device_resp or device_resp.status_code != 200: + print("Device list request failed on page {}: {}".format(page, device_resp.status_code if device_resp else "no response")) + break + + data = json_decode(device_resp.body) + response = data.get("response", {}) + devices = response.get("devices", []) + if not devices: + break + + for d in devices: + device_id = d.get("deviceudid") or d.get("serial_number") or "" + if not device_id: + continue + + hostnames = collect_hostnames(d) + + wifi_mac = d.get("wifi_mac_address") + eth_mac = d.get("ethernet_mac_address") + wifi_ips = [] + if d.get("last_ip_beat"): + wifi_ips.append(d.get("last_ip_beat")) + eth_ips = [] + if d.get("last_lan_ip"): + eth_ips.append(d.get("last_lan_ip")) + + network_interfaces = [] + + if len(wifi_ips) > 0 and wifi_mac: + wifi_iface = build_network_interface(wifi_mac, wifi_ips) + network_interfaces.append(wifi_iface) + + if len(eth_ips) > 0 and eth_mac: + eth_iface = build_network_interface(eth_mac, eth_ips) + network_interfaces.append(eth_iface) + + model = d.get("device_model_name") or d.get("model_name") or d.get("device_model") or d.get("model") or "" + os_name = d.get("os", "") + os_version = d.get("osversion", "") + tags = parse_tags(d.get("tags"), d.get("asset_tag")) + + used_keys = set([ + "deviceudid", + "serial_number", + "device_name", + "devicename", + "HostName", + "LocalHostName", + "hostname", + "os", + "osversion", + "wifi_mac_address", + "ethernet_mac_address", + "last_ip_beat", + "last_lan_ip", + "device_model_name", + "model_name", + "device_model", + "model", + "tags", + "asset_tag", + ]) + custom_attrs = build_custom_attributes(d, used_keys) + + asset = ImportAsset( + id=device_id, + hostnames=hostnames, + os=os_name, + osVersion=os_version, + model=model, + networkInterfaces=network_interfaces if network_interfaces else None, + tags=tags, + customAttributes=custom_attrs, + ) + assets.append(asset) + + page += 1 + + return assets