Search by

spkm / fortinac

spkm

Integrate with the Fortinet FortiNAC-F REST API using SaloonPHP

Package info

github.com/spkm/fortinac

pkg:composer/spkm/fortinac

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-11-01 14:04 UTC

This package is auto-updated.

Last update: 2026-11-01 14:05:57 UTC


README

Banner

Tests Latest version PHP version

A Laravel package for the Fortinet FortiNAC-F REST API, built with Saloon. It has a typed model, a request and an Eloquent-style query builder for every operation in FortiNAC's REST API documentation (version 7.2), grouped by the API's own paths: adapters(), device(), firewallSessions(), host(), logging(), policy(), portalSettings(), radius(), settings(), ssids(), system(), user() and winbind(). Where a path is itself a collection (/host, /user, /device), its method is the builder over those records, with the path's other collections as methods on it.

use spkm\FortiNac\Facades\FortiNac;

$host = FortiNac::host()->findByMac('00:50:56:B5:24:EA');                   // Host or null

$stale = FortiNac::host()
    ->where('status', 0)
    ->where('validForTime', '<', now()->subMonth())
    ->orderBy('validForTime')
    ->limit(50)
    ->get();

foreach (FortiNac::device()->ports()->where('status', 1)->lazy() as $port) {
    // one page of 100 at a time
}

FortiNac::user()->guests()->create([
    'template' => 'Visitor',
    'firstName' => 'Sam',
    'lastName' => 'Jones',
    'email' => 'sam@example.com',
]);

Requirements

  • PHP 8.5
  • Laravel 12.39 or higher, or Laravel 13
  • An admin user on your FortiNAC server with API access (System > Admin Users), whose Admin Profile allows the APIs you need and whose Allowed Subnets include the machine making the calls. FortiNAC answers a call the profile doesn't allow with 403.

Installation

composer require spkm/fortinac
php artisan fortinac:install

The install command publishes config/fortinac.php. Set the server and the token in .env:

FORTINAC_BASE_URL=https://nac.example.com:8443
FORTINAC_TOKEN=your-api-token

FortiNAC's REST API listens on port 8443 by default. A server with a self-signed certificate is best verified against its own certificate: export it from the appliance and set FORTINAC_VERIFY=/path/to/fortinac.pem. FORTINAC_VERIFY=false turns verification off, which lets anyone on the network path read the token; keep that for a lab.

Usage

Two ways to call FortiNAC

Resources give an Eloquent-style builder for each collection, reached from the API group it belongs to. They return models and throw a FortiNacApiException when FortiNAC answers with an error:

$host = FortiNac::host()->find(1904);                                   // Host or null
$count = FortiNac::host()->where('status', 1)->count();
$ports = FortiNac::device()->ports()->orderBy('name')->all();           // Collection of Port

Requests are plain Saloon, for when you want the response itself:

use spkm\FortiNac\Query\Query;
use spkm\FortiNac\Requests\Host\Host\GetHostsRequest;

$response = FortiNac::send(new GetHostsRequest(Query::make()->where('role', '==', 'Staff')->count(50)));
$response->status();
$response->json();
$response->dto();   // the same models the resource gives; throws FortiNacApiException for an error

FortiNac::get(), post(), put() and delete() send any path, and FortiNac::from('/some-path') gives a builder over a collection the package has no resource for, returning arrays. Every resource, request and model is listed in ENDPOINTS.md.

Resources

Each API is a method on the facade, and each collection a method on the API. An API whose own path is a collection (host(), user(), device()) is the builder over those records, and its other collections hang off it (host()->fingerprints(), user()->guests(), device()->ports()); an API with one collection, such as adapters(), is reached directly. A collection gives a builder; a setting FortiNAC keeps one of (such as settings()->proxy()) gives get() and update(); an action FortiNAC performs is a method on the collection.

FortiNac::host()->find(1904);                                           // by id, null when there is none
FortiNac::host()->findByIp('10.1.2.3');
FortiNac::host()->update(1904, ['notes' => 'Lab laptop']);
FortiNac::host()->delete(1904);
FortiNac::host()->deleteMany([1904, 1905]);                              // POST /host/delete
FortiNac::host()->enable(1904);
FortiNac::host()->setRole(['id' => 1904, 'role' => 'Staff']);           // the parameters the operation documents
FortiNac::host()->fingerprints()->get();                                // another collection under /host
FortiNac::adapters()->countAll();
FortiNac::settings()->proxy()->update(['httpProxyHost' => 'proxy.example.com']);
FortiNac::system()->groups()->members(12);                             // GroupMembers or null

Parameters an operation documents without a JSON body (an id, a mac, a rank) go in the last array argument and are sent in the query string; a record goes in the body as JSON.

Examples

Every device registered to a user, by the host record's owner (the user ID, matched ignoring case):

use spkm\FortiNac\Data\Host\Host;

$devices = FortiNac::host()->where('owner', 'janedoe')->get();         // Collection of Host

foreach ($devices as $device) {
    echo $device->hostName, ' ', implode(', ', $device->adapters ?? []), PHP_EOL;   // the MAC addresses
}

FortiNAC's own lookup gives the same hosts, and the ones the user is logged on to right now:

$mine = FortiNac::host()->forUser(['userID' => 'janedoe']);              // HostsForUser
$mine->ownedHosts;                                                       // Collection of Host
$mine->loggedOnHosts;                                                    // Collection of Host

The active devices registered to a user that have been on the network in the last month. FortiNAC's filter compares lastSuccessfulPoll (when it last saw the host) as a timestamp, so that part runs on the server; disabled and expired are booleans, which FortiNAC's filter ignores, so they are checked on the records that come back:

$active = FortiNac::host()
    ->where('owner', 'janedoe')
    ->where('lastSuccessfulPoll', '>', now()->subMonth())
    ->get()
    ->reject(fn (Host $device) => $device->disabled || $device->expired);

$online = $active->filter(fn (Host $device) => $device->connected);   // on the network at this moment

The query builder

FortiNAC filters, sorts and pages in the query string, and the builder writes those parameters for you: one filter parameter per condition (FortiNAC ANDs them), sortKey and sortDir, start and count.

FortiNac::host()
    ->where('role', 'Staff')                          // filter=role==Staff
    ->where('creationTime', '>', now()->subWeek())    // filter=creationTime>1767225600000
    ->whereNot('status', 0)                           // filter=status!=0
    ->whereContains('hostName', 'LAB')                // filter=hostName=@*LAB*
    ->whereIn('type', [1, 9])                         // filter=type==1,type==9
    ->orderByDesc('creationTime')
    ->forPage(2, 50);
Builder FortiNAC
where($field, $value), where($field, '==', $value) field==value, matched as a whole and ignoring case
where($field, '!=', $value), whereNot($field, $value) field!=value
whereContains($field, $text), whereNotContains(...) field=@*text*, field!@*text*
where($field, '=@', 'LAB-*') a pattern of your own, with * wildcards
where($field, '>', $value) and <, >=, <= field>value
whereBetween($field, [$from, $to]) two conditions, field>=from and field<=to
whereIn($field, [...]), orWhere(...) alternatives joined with a comma in one parameter
orderBy($field), orderByDesc($field) sortKey, sortDir
offset($n), limit($n), forPage($page, $perPage) start, count

A value is sent as text to match, never as grammar: a dot, backslash, comma or star in it is escaped, so a value from a form cannot add an alternative or a wildcard of its own (where($field, '=@', $pattern) is the one place a star is a wildcard). Dates are sent as Unix timestamps in milliseconds, which is what FortiNAC compares. Only a record's own top-level fields filter (hostName, owner, type, status, creationTime); FortiNAC ignores a condition on a nested field such as adapters or ips.ip.

get() returns the matching records (as many as limit() asked for, or all of them); first(), count(), exists(), find($id) and findOrFail($id) do what they say; all() and lazy() page through every match, 100 at a time (fortinac.pagination.per_page), and paginate() gives the paginator itself. count() asks the collection's count endpoint, which honours the filter; the total FortiNAC sends with a list does not.

Models

Every record is a readonly model with FortiNAC's own property names and types: $host->hostName, $host->creationTime (a CarbonImmutable), $host->adapters. FortiNAC's records carry more fields than its documentation shows, so a model keeps the ones it has no property for in $extra and sends them back when the model is written. toArray() and json_encode() give the record as FortiNAC would send it, except for its secrets: a property that holds a password, token or key (User::$apiAccessToken, User::$hashedPassword, AaaServer::$secret and the like) is left out, so a model that is logged, cached or returned from a route does not carry them; toArray(withHidden: true) includes them, and hidden() names them.

Model::from($record) is for records FortiNAC sent, since it keeps every key; build a model from request input with its constructor, so only the fields you name are sent.

Writing

create(), update() and the action methods take an array of FortiNAC's field names or a model. FortiNAC answers a write with the record as stored, or with nothing but its status; the methods return the model, null, or true accordingly. A record id must be a whole number; '12abc' is refused rather than read as 12.

Some operations FortiNAC documents take a secret as a parameter rather than in a body, such as user()->setPassword(['userID' => ..., 'password' => ...]); the package sends it the way FortiNAC reads it, in the query string, where a proxy or the appliance's own access log may record it.

Errors

FortiNAC wraps every answer in {"status", "errorMessage", "results"}. A resource method throws a FortiNacApiException for an error status, and for a 200 whose status is not success; a request's dto() does the same. errorMessage() gives FortiNAC's explanation, and getResponse() the response.

FortiNAC answered Exception
404 NotFoundException (find() returns null instead)
401, 403 UnauthorizedException
400, 405, 406, 415, 422 InvalidRequestException
408, 5xx, or a success that isn't JSON ServerErrorException
a success whose status isn't success FailedException

Several servers

Add further servers under connections in config/fortinac.php and reach them with FortiNac::connection('name'), which gives a connector with the same resources and methods as the facade.

Caching, timeouts and retries

FORTINAC_CACHE=true caches GET responses for fortinac.cache.seconds in FORTINAC_CACHE_STORE (the default cache store when unset), one entry per server, token and query, so a connection with another token never reads what this one fetched; an answer FortiNAC marked as failed is not kept, and a request for a password or secret is never cached. The records themselves are stored as the cache store keeps them, so use a store the application trusts. FORTINAC_CONNECT_TIMEOUT and FORTINAC_REQUEST_TIMEOUT are in seconds. FORTINAC_RETRIES retries a GET that failed with a connection error, 408, 429 or 5xx, with exponential backoff from fortinac.retries.interval_ms; writes are never retried.

Testing

composer test

The suite fakes FortiNAC with Saloon's MockClient; nothing reaches a real server.

Contributing

Pull requests are welcome. Run composer format, composer analyse and composer test before opening one.

Security

If you discover a security issue, email hello@spkmitchell.co.uk rather than opening an issue.

Credits

Licence

MIT.