Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
10 changes: 8 additions & 2 deletions docs/integrations.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
74 changes: 74 additions & 0 deletions moysle/README.md
Original file line number Diff line number Diff line change
@@ -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": "<EMAIL>", "password": "<PASSWORD>"}` or `{"username": "<EMAIL>", "password": "<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": "<API_TOKEN>",
"email": "<EMAIL>",
"password": "<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 <token>" \
-H "Content-Type: application/json" \
-d '{
"accessToken": "<API_TOKEN>",
"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": "<EMAIL>", "password": "<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`
1 change: 1 addition & 0 deletions moysle/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "Moysle", "type": "inbound" }
242 changes: 242 additions & 0 deletions moysle/custom-integration-moysle.star
Original file line number Diff line number Diff line change
@@ -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