//native
/**
* Upsert firewall address
* Declaratively ensure an IPv4 firewall address object exists in an ADOM with the given values. Reads the current object, returns the field-level diff and, unless dry_run is true (the default), creates the object or updates only the fields that differ. Fields left empty are not managed.
*/
export async function main(
auth: RT.Fortimanager,
adom: string,
name: string,
type: "ipmask" | "iprange" | "fqdn",
subnet: string | undefined,
start_ip: string | undefined,
end_ip: string | undefined,
fqdn: string | undefined,
comment: string | undefined,
dry_run: boolean = true
) {
// The ADOM is a segment of the JSON-RPC url: a "/" would address another table.
if (adom.includes("/")) throw new Error(`Invalid adom: ${adom}`)
const table = `/pm/config/adom/${adom}/obj/firewall/address`
const rpc = async (method: string, params: { [key: string]: any }) => {
const response = await fetch(
`${auth.base_url.replace(/\/+$/, "")}/jsonrpc`,
{
method: "POST",
headers: {
Authorization: `Bearer ${auth.token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ id: 1, method, params: [params], verbose: 1 }),
}
)
if (!response.ok) {
throw new Error(`${response.status} ${await response.text()}`)
}
const json = await response.json()
const result = json.result?.[0]
if (result?.status?.code !== 0) {
throw new Error(
`FortiManager error: ${JSON.stringify(json.result ?? json)}`
)
}
return result
}
// Intended state. FortiManager stores subnet as [address, netmask].
const desired: { [key: string]: any } = { type }
if (type === "ipmask") {
if (!subnet) throw new Error("subnet is required when type is ipmask")
desired.subnet = toAddressAndMask(subnet)
} else if (type === "iprange") {
if (!start_ip || !end_ip)
throw new Error("start_ip and end_ip are required when type is iprange")
desired["start-ip"] = start_ip
desired["end-ip"] = end_ip
} else {
if (!fqdn) throw new Error("fqdn is required when type is fqdn")
desired.fqdn = fqdn
}
if (comment) desired.comment = comment
// Current state.
const found = await rpc("get", { url: table, filter: ["name", "==", name] })
const current = (found.data ?? [])[0]
const diff: { [key: string]: { current: any; desired: any } } = {}
for (const [key, value] of Object.entries(desired)) {
const now = current?.[key]
if (normalize(now) !== normalize(value)) {
diff[key] = { current: now ?? null, desired: value }
}
}
const changed = Object.keys(diff).length > 0
const operation = !current ? "create" : changed ? "update" : "none"
if (dry_run !== false || !changed) {
return { changed, operation, diff, applied: false }
}
let result
if (!current) {
result = await rpc("add", { url: table, data: { name, ...desired } })
} else {
// update on the table URL, keyed by name in the data: no escaping of names containing "/".
const update: { [key: string]: any } = { name }
for (const key of Object.keys(diff)) update[key] = desired[key]
result = await rpc("update", { url: table, data: update })
}
return { changed, operation, diff, applied: true, result }
}
// Accepts "10.0.0.0/24" or "10.0.0.0 255.255.255.0"; returns ["10.0.0.0", "255.255.255.0"].
function toAddressAndMask(value: string): string[] {
const trimmed = value.trim()
if (trimmed.includes("/")) {
const [address, bits] = trimmed.split("/")
const prefix = Number(bits)
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
throw new Error(`Invalid prefix length in subnet: ${value}`)
}
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
return [address, [24, 16, 8, 0].map((s) => (mask >>> s) & 255).join(".")]
}
const parts = trimmed.split(/\s+/)
if (parts.length !== 2) {
throw new Error(
`subnet must be "address/prefix" or "address netmask": ${value}`
)
}
return parts
}
function normalize(value: any): string {
if (value == null) return ""
if (Array.isArray(value)) return value.map(String).join(" ")
return String(value).trim()
}
Submitted by hugo989 6 hours ago