Sync device inventory from Fleet into Snipe-IT. Written in Go.
Inspired by grokability/jamf2snipe — same purpose, but sourced from Fleet (osquery-based, cross-platform) instead of Jamf Pro, with a webhook listener for near-real-time updates and richer mapping options (gjson, policies, saved queries, labels).
- One binary, two modes —
sync(full reconciliation, run from cron) andserve(HTTP listener for Fleet activity webhooks; pulls one host per event). - Five overlapping ways to map data into Snipe-IT custom fields: gjson paths, policy pass/fail, saved-query result columns, per-label boolean, full label list.
- Idempotent
setupthat creates the custom fields in Snipe-IT, associates them with your fieldset, and writes the resultingfield_mappingback to yoursettings.yaml. - Hand-rolled Fleet client — Bearer auth, paginated listing,
Retry-After-aware backoff. Nogithub.com/fleetdm/fleet/v4import bloat. michellepellon/go-snipeitfor Snipe-IT, wrapped with dry-run enforcement and token-bucket rate limiting.- Device images for Apple hardware via appledb.dev, attached to newly-created Snipe-IT models.
--dry-rungated at every mutation; local cache for offline dev (--use-cache).- Custom-field rejection retry — if Snipe-IT rejects a field for being outside the model's fieldset, fleet2snipe strips it and retries so the rest of the update still lands.
- Distroless Dockerfile and sample systemd unit included.
go build ./...
cp settings.example.yaml settings.yaml
$EDITOR settings.yaml # fill in Fleet/Snipe credentials + IDs
./fleet2snipe test # verify connectivity to both
./fleet2snipe setup # create custom fields in Snipe-IT
./fleet2snipe sync --dry-run --verbose # preview
./fleet2snipe sync # do itFleet — create an api_only user (Settings → Users → Create user → check API only), then copy their API token. Dedicate the account: any other login as that user rotates the token.
Snipe-IT — Account → Manage API Keys → Create New Token.
Set credentials via settings.yaml or env vars: FLEET_URL, FLEET_TOKEN, SNIPE_URL, SNIPE_API_KEY, FLEET2SNIPE_WEBHOOK_SECRET.
./fleet2snipe sync # full sweep
./fleet2snipe sync --force --verbose # ignore freshness check
./fleet2snipe sync --serial C02XK1JJJG5J # one host
./fleet2snipe sync --identifier <uuid|hostname|serial|node_key>
./fleet2snipe sync --use-cache # replay last fetch from .cache/hosts.json
./fleet2snipe sync --update-only # never create new assetsRun on a cron (every 15 min is typical) as your authoritative reconciliation loop. Fleet doesn't emit events when osquery re-reports, so detail drift (free disk space, IPs, OS minor versions) is only caught by polling.
./fleet2snipe serve --verboseIn Fleet, Settings → Integrations → Automations → Activities webhook, posting to:
https://<your-host>:9090/webhook/fleet?secret=<your-webhook-secret>
The activity payload is treated as a wake-up signal. fleet2snipe extracts every host_id it can find in the batch, dedupes, then GETs /api/v1/fleet/hosts/{id} for each one and reconciles into Snipe-IT.
- No activity-type allowlist — any current or future activity that references a host triggers a refresh.
- A burst (e.g. enrollment + MDM enrolled + software installed for the same host, all landing together) becomes one Fleet pull and one Snipe-IT update.
- A 404 from the detail fetch (host deleted mid-flight) is handled silently.
deleted_host/deleted_multiple_hostsactivities are logged but the Snipe-IT asset is left in place — retire manually. We never auto-delete inventory.
Fleet's other automation webhooks (host status, failing policies, vulnerabilities) fire on operational events rather than inventory changes, so we don't subscribe to them.
Five sources feed the same custom_fields map; values that come back empty are skipped (we never overwrite Snipe-IT data with "").
Auto-populated by fleet2snipe setup. Each entry is either a bare gjson path or an object with path + optional transform. Both forms coexist:
sync:
field_mapping:
_snipeit_fleet_host_id_1: id # bare string — path only
_snipeit_fleet_os_version_2: os_version
_snipeit_mdm_enrollment_3: mdm.enrollment_status
_snipeit_first_label_4: labels.0.name
_snipeit_ram_5: # object form — adds a transform
path: memory
transform: bytes_to_gb # 17179869184 bytes → "17"
_snipeit_storage_6:
path: gigs_total_disk_space
transform: gib_to_gb # 465.5 GiB → "500"Full gjson syntax (arrays, filters, modifiers) is supported on path.
Transforms standardise units and rendering before the value lands in Snipe-IT. Fleet emits memory as int64 bytes and disk space as float GiB (despite the misleading gigs_* field name) — without a transform you'd get inconsistent units across the same fieldset.
| Category | Name | Input | Output |
|---|---|---|---|
| Unit conversion | bytes_to_gb |
int64 bytes | decimal GB (bytes / 10⁹), rounded integer |
bytes_to_gib |
int64 bytes | binary GiB (bytes / 2³⁰), rounded integer — matches About This Mac |
|
bytes_to_mb |
int64 bytes | decimal MB (bytes / 10⁶), rounded integer |
|
bytes_to_tb |
int64 bytes | decimal TB (bytes / 10¹²), rounded integer |
|
gib_to_gb |
float GiB | decimal GB (GiB × 1.073741824), rounded |
|
| Time | unix_to_iso |
int64 seconds-since-epoch | YYYY-MM-DD HH:MM:SS UTC (matches existing RFC3339 normalisation) |
| String | uppercase |
any string | strings.ToUpper |
lowercase |
any string | strings.ToLower |
|
mac_colons |
any MAC-ish string | aa:bb:cc:dd:ee:ff (colon-separated, lowercase) |
|
mac_dashes |
any MAC-ish string | aa-bb-cc-dd-ee-ff (dash-separated, lowercase) |
|
base64_to_mac |
6-byte base64 string | aa:bb:cc:dd:ee:ff — decodes Fleet's ioreg IOMACAddress plist data |
|
| Display | comma_thousands |
integer (or numeric string) | 1,234,567 US-style thousands grouping |
bool_yes_no |
bool / numeric / string | Yes / No for true-ish / false-ish; "" for unknown values |
Empty-on-no-data rule: zero, missing, and unparseable values resolve to "" for the unit conversions and unix_to_iso so we never clobber real Snipe-IT data with a placeholder from a host that hasn't reported in yet. For the cosmetic transforms (comma_thousands, case), a legitimate 0 or empty string passes through unchanged.
MAC normaliser strips every non-hex character then re-inserts the chosen separator between byte pairs, so colon, dash, dot (Cisco aabb.ccdd.eeff), and run-on AABBCCDDEEFF formats all converge to the same form. Inputs that don't reduce to exactly 12 hex characters return "".
Unknown transform names are rejected at config load with an error naming both the bad transform and the field that used it — typos surface immediately rather than per-host.
Every mapping section (field_mapping, policy_mapping, query_mapping, label_mapping) accepts per-platform additions and overrides under sync.per_platform.<platform>.<mapping_type>. The engine merges each platform's block with the corresponding global mapping for hosts of that platform; on key conflict the platform-specific value wins.
sync:
field_mapping:
_snipeit_host_id_1: id # global — applies to every platform
per_platform:
darwin:
field_mapping:
_snipeit_filevault_20:
path: disk_encryption_enabled
transform: bool_yes_no
policy_mapping:
_snipeit_compliance_21: "macOS baseline compliance"
ios:
# iOS has no osquery, so no policy/query mappings — just MDM-derived fields.
field_mapping:
_snipeit_supervised_24:
path: mdm.is_supervised
transform: bool_yes_noResolution: an iOS host gets _snipeit_host_id_1 (from global) and _snipeit_supervised_24 (from per_platform.ios); a darwin host gets _snipeit_host_id_1 plus the FileVault field and policy.
Saved queries referenced under per_platform.<platform>.query_mapping are fetched once per unique query name at warm time — referencing the same query from N platforms still costs one Fleet API call. The report is indexed by host_id so per-host lookups stay O(1) regardless of platform.
populate_policies / populate_labels on the list endpoint are auto-enabled when any mapping (global or per-platform) needs the data, so you don't have to remember to flip the flag when adding a darwin-only policy.
Transform validation runs on per-platform field_mapping entries too — typos in a platform block fail config load with a clear error naming both the platform and the transform name.
sync:
policy_mapping:
_snipeit_filevault_10: "FileVault is enabled"
_snipeit_gatekeeper_11: "Gatekeeper is enabled"Writes "pass" / "fail" / "" per host. Free piggyback on the host detail — populate_policies is auto-enabled when this map is non-empty.
sync:
query_mapping:
_snipeit_kernel_version_12:
query: "Kernel version"
column: "kernel_version"
_snipeit_ad_domain_13:
query: "Joined AD domain"
column: "domain"The saved query must have discard_data=false (i.e. "Save results in Fleet"). Each configured query is fetched once per sync run and indexed by host_id — a 5,000-host fleet with three query mappings costs 3 API calls, not 15,000.
The default primary_mac Fleet returns for macOS hosts is the Private Wi-Fi Address — a per-SSID randomized MAC the OS rotates whenever the host joins a new network. For stable inventory tracking you want the burned-in hardware MAC instead.
Fleet's fleetd agent ships an ioreg osquery table that wraps /usr/sbin/ioreg -a and surfaces IOKit properties — including IOMACAddress on the underlying Wi-Fi adapter, which bypasses Private Wi-Fi Address because IOKit sits below the kernel's MAC-rewriting layer.
Step 1 — in Fleet, create a saved query (Settings → Queries → Add) named e.g. "macOS hardware Wi-Fi MAC" with Save results in Fleet enabled:
SELECT value AS mac_b64
FROM ioreg
WHERE c = 'IOSkywalkLegacyEthernet' -- macOS 12+ Wi-Fi adapter class
AND key = 'IOMACAddress'
LIMIT 1;(Pre-macOS-12 systems use AppleBCMWLANCore or IO80211Controller. Widen the c = filter if you need to support older versions.)
Step 2 — in fleet2snipe, wire it up under the darwin platform with the base64_to_mac transform. ioreg surfaces IOMACAddress as a plist <data> block which fleetd serialises as base64; base64_to_mac decodes 6 bytes into aa:bb:cc:dd:ee:ff:
sync:
per_platform:
darwin:
query_mapping:
_snipeit_mac_address_1:
query: "macOS hardware Wi-Fi MAC"
column: "mac_b64"
transform: base64_to_mac # "cIzyxNK1" → 70:8c:f2:c4:d2:b5Fleet has to have collected the query result for at least one interval before the first sync sees data. After that, the hardware MAC overrides the rotating primary_mac for darwin hosts and stays stable across SSID changes.
Tracking upstream: this is filed as a Fleet bug at fleetdm/fleet#46112. When Fleet adds a stable hardware_mac field, this recipe goes away.
sync:
label_mapping:
_snipeit_is_engineering_15: "Engineering laptops"
_snipeit_is_kiosk_16: "Kiosks"Writes "yes" if the host belongs to the named label, "no" otherwise. Auto-enables populate_labels.
sync:
labels_field: _snipeit_fleet_labels_17A single Snipe-IT field that receives an alphabetised, comma-separated list of every label the host belongs to. Sorted output means a stable membership set produces a stable field value — no PATCH churn.
Mirrors jamf2snipe -u / -ui / -uf but generalised across whichever Fleet field carries the user identifier. Disabled by default.
sync:
checkout:
enabled: true
user_field: "end_users.0.idp_username" # gjson path into the host JSON
match_field: "username" # snipe field: username | email | employee_num
mode: "assign" # assign | sync | forceuser_fieldis any gjson path that resolves to a single string. Good choices:end_users.0.idp_username(Fleet Premium with IDP),end_users.0.email, orusers.#(type=="regular").username(first regular OS user from osquery).match_fieldis the Snipe-IT user field to look the value up against. Match is case-insensitive.mode:assignonly checks out when the asset is currently unassigned (default, like-u);syncalso reassigns when the user differs (like-ui);forcealways (re)assigns (like-uf).- All Snipe-IT users are loaded once at warm time and indexed for O(1) lookups, so per-host sync stays cheap regardless of fleet size.
- Reassignments are handled correctly: Snipe-IT's checkout endpoint refuses to overwrite an existing assignment, so we check the asset in first when the desired user differs.
- A Fleet user that has no Snipe-IT counterpart is logged at info and skipped — fleet2snipe never auto-creates users.
fleet2snipe setup is idempotent and safe to re-run. It creates / updates a baseline set of Fleet: … custom fields in Snipe-IT, associates them with your configured fieldset, and rewrites sync.field_mapping in settings.yaml (preserving comments) with the resulting db_column_names.
Manual prereqs in Snipe-IT (one time):
- Create at least one fieldset →
snipe_it.custom_fieldset_id. Optionally create one fieldset per platform (e.g. one for macOS, one for Windows, one for mobile) and list them undersnipe_it.fieldset_ids—syncwill attach the right fieldset to each auto-created model based on the host's Fleet platform.setupassociates everyFleet: …custom field with every configured fieldset in one idempotent pass. - Create a status label for new assets →
snipe_it.default_status_id. - Create one or more model categories (e.g. per OS family) →
snipe_it.category_ids.
Manufacturers can be left blank — sync auto-creates them from Fleet's hardware_vendor.
Analogous to jamf2snipe's computer_custom_fieldset_id / mobile_custom_fieldset_id, but generalised across every Fleet platform:
snipe_it:
custom_fieldset_id: 4 # fallback for platforms not listed below
fieldset_ids:
darwin: 5 # MacBooks / iMacs / Mac minis
ios: 6 # iPhones
ipados: 6 # iPads share the iOS fieldset
windows: 7
linux: 8
chrome: 9setup writes every field to all of those fieldsets. You can then prune fields that don't apply per-platform inside Snipe-IT (e.g. remove Fleet: Disk Encryption from the Chrome fieldset).
Fleet collects data differently depending on what's running on the host, so several mapping sources are available only on osquery platforms. Plan your fieldsets accordingly — per-platform fieldset_ids is the easiest way to keep iOS assets from being haunted by Fleet: CPU Brand columns that will never populate.
| Platform | Data source | What works | What's missing / thin |
|---|---|---|---|
| darwin / linux / windows | Fleet osquery agent | Everything: full field_mapping, policy_mapping, query_mapping, label_mapping, software inventory |
— |
| chrome | fleetd-chrome browser extension |
Identity, MDM state, labels, a subset of osquery-style policies and saved queries via the extension's supported tables | Tables that don't exist in the extension's narrower osquery surface (most filesystem / kernel / system_info columns), software inventory, things like cpu_brand/memory that the extension doesn't expose |
| ios / ipados | Fleet MDM (no osquery, no extension) | Hardware identity (serial / model / uuid), OS version, MDM enrollment state, labels via Smart criteria, end_users | policy_mapping (no osquery to evaluate), query_mapping (no osquery to run), software inventory, cpu_brand, memory, gigs_*, host-detail disk_encryption_enabled |
| android | Fleet MDM only (no osquery, no extension) | Same surface as iOS: identity, OS version, MDM enrollment, labels, end_users | Same gaps as iOS |
| tvos / visionos | Fleet MDM only (no osquery) | Same as iOS — basic identity only | Same as iOS |
Practical implications:
- A
policy_mappingentry for"FileVault enabled"will always return""on iOS/iPadOS hosts — Fleet has no way to evaluate it. The engine's "empty-on-missing" rule means the field stays blank rather than getting an incorrect"fail". query_mappingresults only land for hosts that actually ran the query. iOS / Chrome hosts return no rows; the per-host lookup misses and the field stays blank.- Software-inventory–based mappings (
field_mappingpaths intosoftware[]) are osquery-only. Enablepopulate_software=trueand use them only on osquery platforms. - The
Fleet: Disk Encryptionfield setup creates is meaningful on darwin/windows (osquery reads filevault/bitlocker state), partially populated on iOS via MDM (you getmdm.disk_encryption_enablednot the host-detaildisk_encryption_enabled), and absent on Linux unless you wire up your own osquery extension or saved query. - Use
platform_filterto skip platforms entirely if a sync run targets a specific tier (e.g. only push macOS into a "Laptop" category).
The transforms (bool_yes_no, bytes_to_gb, etc.) all work uniformly regardless of platform — they operate on whatever value Fleet does return, including the empty case.
- Match key:
hardware_serial. Hosts with no serial are skipped. Two Snipe-IT assets sharing a serial → flagged and skipped to avoid clobbering the wrong record. - Freshness check: a host whose Fleet
detail_updated_atis older than Snipe-IT'supdated_atis skipped. Use--force(orsync.force: true) to ignore. - Asset tag: template-driven.
sync.asset_tag.templateis a string with{gjson.path}placeholders interpolated from the Fleet host JSON (e.g."CG-{hardware_serial}","{platform}-{id}").sync.asset_tag.platform_templatesoverrides per Fleet platform (mirrors kandji2snipe's per-platform patterns). An explicit empty string asks Snipe-IT to auto-assign (jamf2snipe's--auto_incrementing). Legacysync.asset_tag_prefixis still honored as a shortcut for"{prefix}{id}". - Model creation: uses
hardware_model(e.g.MacBookPro17,1) as both the model name and number, attaches the fieldset, and on Apple devices fetches an image from appledb.dev whensync.model_images: true. - Custom-field rejection retry: if Snipe-IT rejects fields with "not available on this Asset Model's fieldset", fleet2snipe strips the bad keys and retries once so the rest of the update applies. Re-run
fleet2snipe setupto fix the underlying fieldset config. - Platform filtering:
sync.platform_filter: ["darwin", "windows"]to limit which platforms get synced.
docker build -t fleet2snipe .
docker run --rm \
-e FLEET_URL=https://fleet.example.com \
-e FLEET_TOKEN=... \
-e SNIPE_URL=https://snipe.example.com \
-e SNIPE_API_KEY=... \
-e FLEET2SNIPE_WEBHOOK_SECRET=... \
-v $(pwd)/settings.yaml:/app/settings.yaml:ro \
-p 9090:9090 fleet2snipe serveOr run a one-shot sync from a Kubernetes / Cloud Run cron with the same flags.
github.com/fleetdm/fleet/v4/server/service exposes a usable Go client, but importing it drags in the full Fleet server module (MySQL, NanoMDM, AWS SDKs, MaxMind, k8s libs, …). For a small CLI that only needs five endpoints — list hosts, get host, list queries, get query report, list labels — a few hundred lines of net/http is the right trade.
MIT