Skip to content

Commit 039b3f4

Browse files
committed
feat: netifyd licensing
1 parent d45de2f commit 039b3f4

File tree

9 files changed

+401
-0
lines changed

9 files changed

+401
-0
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Logic\NetifydLicenceRepository;
6+
use App\NetifydLicenceType;
7+
use Exception;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Support\Facades\Cache;
10+
use Illuminate\Support\Facades\Log;
11+
12+
class NetifyLicenceController extends Controller
13+
{
14+
public function community(NetifydLicenceRepository $licenceProvider): JsonResponse
15+
{
16+
return $this->run($licenceProvider, NetifydLicenceType::COMMUNITY);
17+
}
18+
19+
private function run(NetifydLicenceRepository $licenceProvider, NetifydLicenceType $licenceType): JsonResponse
20+
{
21+
// If license is in cache, return it.
22+
if (Cache::has($licenceType->cacheLabel())) {
23+
Log::debug('Cache hit, serving license.');
24+
25+
return response()->json(Cache::get($licenceType->cacheLabel()));
26+
}
27+
28+
Log::debug('Cache miss, listing licenses.');
29+
// Check if the community license is on the remote server.
30+
try {
31+
$licences = $licenceProvider->listLicences();
32+
} catch (Exception $e) {
33+
return response()->json(['message' => $e->getMessage()], 500);
34+
}
35+
$license = array_find($licences['data'], fn ($item) => $item['issued_to'] == $licenceType->label());
36+
// If it doesn't exist, create it.
37+
if ($license == null) {
38+
Log::debug('Requested license not found, creating it.');
39+
try {
40+
$license = $licenceProvider->createLicence($licenceType);
41+
} catch (Exception $e) {
42+
return response()->json(['message' => $e->getMessage()], 500);
43+
}
44+
}
45+
// Got license, checking if everything is in place.
46+
Log::debug('License found, checking renewal/expiration.');
47+
$expiration = $license['expire_at']['unix'];
48+
$creation = $license['created_at']['unix'];
49+
$renewalThreshold = ($expiration - $creation) / 2 + $creation;
50+
$now = now()->unix();
51+
if ($renewalThreshold < $now) {
52+
Log::debug('Licence can be renewed, renewing.');
53+
try {
54+
$license = $licenceProvider->renewLicence($licenceType, $license['serial']);
55+
} catch (Exception $e) {
56+
return response()->json(['message' => $e->getMessage()], 500);
57+
}
58+
}
59+
60+
$expiration = $license['expire_at']['unix'];
61+
$creation = $license['created_at']['unix'];
62+
Cache::put($licenceType->cacheLabel(), $license, ($expiration - $creation) / 2);
63+
64+
return response()->json($license);
65+
}
66+
67+
public function enterprise(NetifydLicenceRepository $licenceProvider): JsonResponse
68+
{
69+
return $this->run($licenceProvider, NetifydLicenceType::ENTERPRISE);
70+
}
71+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace App\Logic;
4+
5+
use App\NetifydLicenceType;
6+
use Exception;
7+
use Illuminate\Http\Client\ConnectionException;
8+
use Illuminate\Http\Client\RequestException;
9+
use Illuminate\Support\Facades\Http;
10+
11+
class NetifydLicenceRepository
12+
{
13+
public function __construct(private string $endpoint, private string $apiKey) {}
14+
15+
/**
16+
* @throws Exception
17+
*/
18+
public function listLicences(): array
19+
{
20+
try {
21+
return Http::withHeader('x-api-key', $this->apiKey)
22+
->get($this->endpoint.'/api/v2/integrator/licenses?format=netifyd')
23+
->throw()
24+
->json('data');
25+
} catch (ConnectionException|RequestException $e) {
26+
throw new Exception('Could not list licences from netifyd: '.$e->getMessage());
27+
}
28+
}
29+
30+
/**
31+
* @throws Exception
32+
*/
33+
public function createLicence(NetifydLicenceType $licenceType): array
34+
{
35+
try {
36+
return Http::withHeader('x-api-key', $this->apiKey)
37+
->post(config('netifyd.endpoint').'/api/v2/integrator/licenses', [
38+
'format' => 'object',
39+
'issued_to' => $licenceType->label(),
40+
'duration_days' => $licenceType->durationDays(),
41+
'description' => 'License provided to'.$licenceType->label().'instances.',
42+
'entitlements' => [
43+
'netify-proc-aggregator',
44+
'netify-proc-flow-actions',
45+
],
46+
])->throw()
47+
->json('data');
48+
} catch (ConnectionException|RequestException $e) {
49+
throw new Exception('Could not create licence on netifyd: '.$e->getMessage());
50+
}
51+
}
52+
53+
/**
54+
* @throws Exception
55+
*/
56+
public function renewLicence(NetifydLicenceType $licenceType, string $serial): array
57+
{
58+
try {
59+
return Http::withHeader('x-api-key', config('netifyd.api-key'))
60+
->post(config('netifyd.endpoint').'/api/v2/integrator/licenses/'.$serial.'/renew', [
61+
'duration_days' => $licenceType->durationDays(),
62+
])->throw()
63+
->json('data');
64+
65+
} catch (ConnectionException|RequestException $e) {
66+
throw new Exception('Could not renew licence on netifyd: '.$e->getMessage());
67+
}
68+
}
69+
}

app/NetifydLicenceType.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App;
4+
5+
use Illuminate\Support\Str;
6+
7+
enum NetifydLicenceType: string
8+
{
9+
case COMMUNITY = 'community';
10+
case ENTERPRISE = 'enterprise';
11+
12+
public function label(): string
13+
{
14+
return match ($this) {
15+
self::COMMUNITY => 'NethSecurity Community Edition',
16+
self::ENTERPRISE => 'NethSecurity Enterprise Edition',
17+
};
18+
}
19+
20+
public function cacheLabel(): string
21+
{
22+
return Str::slug($this->label());
23+
}
24+
25+
public function durationDays(): int
26+
{
27+
return match ($this) {
28+
self::COMMUNITY => 3,
29+
self::ENTERPRISE => 7,
30+
};
31+
}
32+
}

app/Providers/AppServiceProvider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Providers;
44

55
use App\Logic\LicenceVerification;
6+
use App\Logic\NetifydLicenceRepository;
67
use Illuminate\Support\ServiceProvider;
78

89
class AppServiceProvider extends ServiceProvider
@@ -15,6 +16,9 @@ public function register(): void
1516
$this->app->singleton(LicenceVerification::class, function () {
1617
return new LicenceVerification(config('repositories.endpoints.enterprise'), config('repositories.endpoints.community'));
1718
});
19+
$this->app->singleton(NetifydLicenceRepository::class, function () {
20+
return new NetifydLicenceRepository(config('netifyd.api-key'), config('netifyd.endpoint'));
21+
});
1822
}
1923

2024
/**

config/netifyd.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
/**
4+
* Netifyd configuration
5+
*/
6+
7+
return [
8+
'endpoint' => env('NETIFYD_API_ENDPOINT', 'https://agents.netify.ai'),
9+
'api-key' => env('NETIFYD_API_KEY'),
10+
];

phpunit.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@
3131
<env name="SESSION_DRIVER" value="array"/>
3232
<env name="TELESCOPE_ENABLED" value="false"/>
3333
<env name="REPOSITORY_MILESTONE_TOKEN" value="testing" />
34+
<env name="NETIFYD_API_KEY" value="key" />
3435
</php>
3536
</phpunit>

routes/web.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use App\Http\Controllers\NetifyLicenceController;
34
use App\Http\Controllers\RepositoryController;
45
use App\Http\Middleware\CommunityLicenceCheck;
56
use App\Http\Middleware\EnterpriseLicenceCheck;
@@ -28,4 +29,8 @@
2829
Route::get('/repository/enterprise/{repository:name}/{path}', RepositoryController::class)
2930
->where('path', '.*')
3031
->middleware(EnterpriseLicenceCheck::class);
32+
33+
Route::get('/netifyd/enterprise/licence', [NetifyLicenceController::class, 'enterprise']);
3134
});
35+
36+
Route::get('/netifyd/community/licence', [NetifyLicenceController::class, 'community']);
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
use App\Logic\LicenceVerification;
4+
use App\Logic\NetifydLicenceRepository;
5+
use App\NetifydLicenceType;
6+
use Illuminate\Support\Facades\Cache;
7+
use Illuminate\Support\Facades\Http;
8+
use Mockery\MockInterface;
9+
10+
use function Pest\Laravel\get;
11+
use function Pest\Laravel\partialMock;
12+
use function Pest\Laravel\withBasicAuth;
13+
14+
it('cannot access enterprise licence without credentials', function () {
15+
get('/netifyd/enterprise/licence')
16+
->assertUnauthorized()
17+
->assertHeader('WWW-Authenticate', 'Basic');
18+
});
19+
20+
it('can access enterprise licence with credentials', function () {
21+
partialMock(LicenceVerification::class, function (MockInterface $mock) {
22+
$mock->expects('enterpriseCheck')
23+
->with('system-id', 'secret')
24+
->andReturnTrue();
25+
});
26+
Cache::expects('has')->with(NetifydLicenceType::ENTERPRISE->cacheLabel())->andReturnTrue();
27+
Cache::expects('get')->with(NetifydLicenceType::ENTERPRISE->cacheLabel())->andReturn(['license_key' => 'cache']);
28+
withBasicAuth('system-id', 'secret')
29+
->get('/netifyd/enterprise/licence')
30+
->assertOk()
31+
->assertJson(['license_key' => 'cache']);
32+
});
33+
34+
it('serves correctly cache if present', function () {
35+
Cache::expects('has')->with(NetifydLicenceType::COMMUNITY->cacheLabel())->andReturnTrue();
36+
Cache::expects('get')->with(NetifydLicenceType::COMMUNITY->cacheLabel())->andReturn(['license_key' => 'cached-license-key']);
37+
Http::preventStrayRequests();
38+
Http::fake();
39+
$response = get('/netifyd/community/licence');
40+
$response->assertOk()
41+
->assertJson([
42+
'license_key' => 'cached-license-key',
43+
]);
44+
});
45+
46+
it('handles errors from netifyd server', function () {
47+
partialMock(NetifydLicenceRepository::class, function (MockInterface $mock) {
48+
$mock->expects('listLicences')
49+
->andThrow(new Exception('Netifyd server error'));
50+
});
51+
get('/netifyd/community/licence')
52+
->assertInternalServerError()
53+
->assertJson([
54+
'message' => 'Netifyd server error',
55+
]);
56+
});
57+
58+
it('list licences', function () {
59+
$expiration = now()->addDays(2);
60+
$creation = now()->subDay();
61+
$licence = [
62+
'issued_to' => NetifydLicenceType::COMMUNITY->label(),
63+
'serial' => 'EXAMPLE-COMMUNITY-SERIAL',
64+
'expire_at' => [
65+
'unix' => $expiration->unix(),
66+
],
67+
'created_at' => [
68+
'unix' => $creation->unix(),
69+
],
70+
];
71+
partialMock(NetifydLicenceRepository::class, function (MockInterface $mock) use ($licence) {
72+
$mock->expects('listLicences')
73+
->andReturn([
74+
'data' => [$licence],
75+
]);
76+
});
77+
Cache::expects('has')->with(NetifydLicenceType::COMMUNITY->cacheLabel())->andReturnFalse();
78+
Cache::expects('put')->with(NetifydLicenceType::COMMUNITY->cacheLabel(), $licence, ($expiration->unix() - $creation->unix()) / 2);
79+
get('/netifyd/community/licence')->assertOk()->json($licence);
80+
});
81+
82+
it('licence not found', function () {
83+
partialMock(NetifydLicenceRepository::class, function (MockInterface $mock) {
84+
$mock->expects('listLicences')
85+
->andReturn([
86+
'data' => [],
87+
]);
88+
$mock->expects('createLicence')
89+
->with(NetifydLicenceType::COMMUNITY)
90+
->andreturn([]);
91+
});
92+
get('/netifyd/community/licence');
93+
});
94+
95+
it('cannot create new licence', function () {
96+
partialMock(NetifydLicenceRepository::class, function (MockInterface $mock) {
97+
$mock->expects('listLicences')
98+
->andReturn([
99+
'data' => [],
100+
]);
101+
$mock->expects('createLicence')
102+
->with(NetifydLicenceType::COMMUNITY)
103+
->andThrow(new Exception('Cannot create licence'));
104+
});
105+
get('/netifyd/community/licence')
106+
->assertInternalServerError()
107+
->assertJson(['message' => 'Cannot create licence']);
108+
});
109+
110+
it('renews older licence', function () {
111+
$licence = [
112+
'issued_to' => NetifydLicenceType::COMMUNITY->label(),
113+
'serial' => 'EXAMPLE-COMMUNITY-SERIAL',
114+
'expire_at' => [
115+
'unix' => now()->addDay()->unix(),
116+
],
117+
'created_at' => [
118+
'unix' => now()->subDays(3)->unix(),
119+
],
120+
];
121+
partialMock(NetifydLicenceRepository::class, function (MockInterface $mock) use ($licence) {
122+
$mock->expects('listLicences')
123+
->andReturn([
124+
'data' => [
125+
$licence,
126+
],
127+
]);
128+
$mock->expects('renewLicence')
129+
->with(NetifydLicenceType::COMMUNITY, 'EXAMPLE-COMMUNITY-SERIAL')
130+
->andReturn($licence);
131+
});
132+
get('/netifyd/community/licence')
133+
->assertOk();
134+
});
135+
136+
it('cannot renew licence', function () {
137+
$licence = [
138+
'issued_to' => NetifydLicenceType::COMMUNITY->label(),
139+
'serial' => 'EXAMPLE-COMMUNITY-SERIAL',
140+
'expire_at' => [
141+
'unix' => now()->addDay()->unix(),
142+
],
143+
'created_at' => [
144+
'unix' => now()->subDays(3)->unix(),
145+
],
146+
];
147+
partialMock(NetifydLicenceRepository::class, function (MockInterface $mock) use ($licence) {
148+
$mock->expects('listLicences')
149+
->andReturn([
150+
'data' => [
151+
$licence,
152+
],
153+
]);
154+
$mock->expects('renewLicence')
155+
->with(NetifydLicenceType::COMMUNITY, 'EXAMPLE-COMMUNITY-SERIAL')
156+
->andThrow(new Exception('Cannot renew licence'));
157+
});
158+
get('/netifyd/community/licence')
159+
->assertInternalServerError()
160+
->assertJson(['message' => 'Cannot renew licence']);
161+
});

0 commit comments

Comments
 (0)