fix: add missing HandleLicenseFeatures method and related changes

- Add HandleLicenseFeatures handler that was missing from license_handlers.go
- Add /api/license/features route to router
- Update AI service and metadata provider
- Update frontend license API and components
- Fix CI build failure caused by tests referencing unimplemented method
This commit is contained in:
rcourtman 2025-12-19 22:59:52 +00:00
parent 65e38fac91
commit 7f05d87809
13 changed files with 257 additions and 30 deletions

View file

@ -180,6 +180,8 @@ jobs:
provenance: false
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache,mode=max
build-args: |
PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
tags: |
ghcr.io/${{ github.repository_owner }}/pulse:staging-${{ needs.extract_version.outputs.tag }}
@ -194,13 +196,17 @@ jobs:
provenance: false
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:buildcache,mode=max
build-args: |
PULSE_LICENSE_PUBLIC_KEY=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
tags: |
ghcr.io/${{ github.repository_owner }}/pulse-docker-agent:staging-${{ needs.extract_version.outputs.tag }}
- name: Build Docker images for integration tests
run: |
docker build -t pulse-mock-github:test tests/integration/mock-github-server
docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 .
docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 --build-arg PULSE_LICENSE_PUBLIC_KEY="$PULSE_LICENSE_PUBLIC_KEY" .
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
- name: Run update integration smoke tests
working-directory: tests/integration
@ -305,6 +311,8 @@ jobs:
run: |
echo "Building release ${{ needs.extract_version.outputs.tag }}..."
./scripts/build-release.sh ${{ needs.extract_version.outputs.version }}
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
- name: Post-build health check
run: |
@ -536,4 +544,3 @@ jobs:
draft: false
target_commitish: ${{ needs.create_release.outputs.target_commitish }}

View file

@ -77,7 +77,9 @@ jobs:
- name: Build Docker images for integration tests
run: |
docker build -t pulse-mock-github:test tests/integration/mock-github-server
docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 .
docker build -t pulse:test -f Dockerfile --target runtime --cache-from ghcr.io/${{ github.repository_owner }}/pulse:buildcache --build-arg BUILDKIT_INLINE_CACHE=1 --build-arg PULSE_LICENSE_PUBLIC_KEY="$PULSE_LICENSE_PUBLIC_KEY" .
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
- name: Run integration diagnostics
working-directory: tests/integration

1
.gitignore vendored
View file

@ -114,6 +114,7 @@ test-*.html
PMG_BACKUP_DETECTION.md
SAFE_TESTING.md
tmp/
.secrets/
# Master plan documents (local only)
PULSE_V4_ISSUES_MASTER_PLAN.md

View file

@ -1,5 +1,6 @@
# syntax=docker/dockerfile:1.7-labs
ARG BUILD_AGENT=1
ARG PULSE_LICENSE_PUBLIC_KEY
# Build stage for frontend (must be built first for embedding)
FROM node:20-alpine AS frontend-builder
@ -22,6 +23,7 @@ RUN --mount=type=cache,id=pulse-npm-cache,target=/root/.npm \
FROM golang:1.24-alpine AS backend-builder
ARG BUILD_AGENT
ARG PULSE_LICENSE_PUBLIC_KEY
WORKDIR /app
# Install build dependencies
@ -49,8 +51,12 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \
VERSION="v$(cat VERSION | tr -d '\n')" && \
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
LICENSE_LDFLAGS="" && \
if [ -n "${PULSE_LICENSE_PUBLIC_KEY}" ]; then \
LICENSE_LDFLAGS="-X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=${PULSE_LICENSE_PUBLIC_KEY}"; \
fi && \
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${VERSION}" \
-ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=${VERSION} ${LICENSE_LDFLAGS}" \
-trimpath \
-o pulse ./cmd/pulse

View file

@ -25,6 +25,12 @@ export interface ClearLicenseResponse {
message?: string;
}
export interface LicenseFeatureStatus {
license_status: string;
features: Record<string, boolean>;
upgrade_url: string;
}
export class LicenseAPI {
private static baseUrl = '/api/license';
@ -32,6 +38,10 @@ export class LicenseAPI {
return apiFetchJSON(`${this.baseUrl}/status`) as Promise<LicenseStatus>;
}
static async getFeatures(): Promise<LicenseFeatureStatus> {
return apiFetchJSON(`${this.baseUrl}/features`) as Promise<LicenseFeatureStatus>;
}
static async activateLicense(licenseKey: string): Promise<ActivateLicenseResponse> {
return apiFetchJSON(`${this.baseUrl}/activate`, {
method: 'POST',

View file

@ -2,6 +2,7 @@ import { Show, createSignal } from 'solid-js';
import { aiChatStore } from '@/stores/aiChat';
import type { Alert } from '@/types/api';
import { formatAlertValue } from '@/utils/alertFormatters';
import { showWarning } from '@/utils/toast';
interface InvestigateAlertButtonProps {
alert: Alert;
@ -10,6 +11,7 @@ interface InvestigateAlertButtonProps {
size?: 'sm' | 'md';
variant?: 'icon' | 'text' | 'full';
class?: string;
licenseLocked?: boolean;
}
/**
@ -18,10 +20,15 @@ interface InvestigateAlertButtonProps {
*/
export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
const [isHovered, setIsHovered] = createSignal(false);
const isLocked = () => props.licenseLocked === true;
const handleClick = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (isLocked()) {
showWarning('Pulse Pro required to investigate alerts with AI.');
return;
}
// Calculate how long the alert has been active
const startTime = new Date(props.alert.startTime);
@ -102,8 +109,10 @@ Please:
hover:text-purple-700 dark:hover:text-purple-300
border border-purple-200/50 dark:border-purple-700/50
hover:border-purple-300 dark:hover:border-purple-600
${isLocked() ? 'opacity-60 cursor-not-allowed hover:from-purple-500/10 hover:to-blue-500/10' : ''}
${props.class || ''}`}
title="Ask AI to investigate this alert"
title={isLocked() ? 'Pulse Pro required to investigate alerts with AI' : 'Ask AI to investigate this alert'}
aria-disabled={isLocked()}
>
<svg
class={`${props.size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4'}`}
@ -138,8 +147,10 @@ Please:
border border-purple-200/50 dark:border-purple-700/50
hover:border-purple-300 dark:hover:border-purple-600
gap-1.5
${isLocked() ? 'opacity-60 cursor-not-allowed hover:from-purple-500/10 hover:to-blue-500/10' : ''}
${props.class || ''}`}
title="Ask AI to investigate this alert"
title={isLocked() ? 'Pulse Pro required to investigate alerts with AI' : 'Ask AI to investigate this alert'}
aria-disabled={isLocked()}
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
@ -167,8 +178,10 @@ Please:
text-white font-medium
shadow-sm hover:shadow-md
gap-2
${isLocked() ? 'opacity-60 cursor-not-allowed hover:from-purple-500 hover:to-blue-500' : ''}
${props.class || ''}`}
title="Ask AI to investigate this alert"
title={isLocked() ? 'Pulse Pro required to investigate alerts with AI' : 'Ask AI to investigate this alert'}
aria-disabled={isLocked()}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path

View file

@ -1,4 +1,4 @@
import { Component, Show, createSignal, onMount, For } from 'solid-js';
import { Component, Show, createSignal, onMount, For, createMemo } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
@ -7,6 +7,7 @@ import { formField, labelClass, controlClass } from '@/components/shared/Form';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { AIAPI } from '@/api/ai';
import { LicenseAPI, type LicenseStatus } from '@/api/license';
import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/ai';
// Providers are now configured via accordion sections, not a single-provider selector
@ -91,6 +92,19 @@ export const AISettings: Component = () => {
// Per-provider test state
const [testingProvider, setTestingProvider] = createSignal<string | null>(null);
const [providerTestResult, setProviderTestResult] = createSignal<{ provider: string; success: boolean; message: string } | null>(null);
const [licenseStatus, setLicenseStatus] = createSignal<LicenseStatus | null>(null);
const hasAlertAnalysisFeature = createMemo(() => {
const status = licenseStatus();
if (!status) return true;
return Boolean(status.valid && status.features?.includes('ai_alerts'));
});
const hasAutoFixFeature = createMemo(() => {
const status = licenseStatus();
if (!status) return true;
return Boolean(status.valid && status.features?.includes('ai_autofix'));
});
const alertAnalysisLocked = createMemo(() => !hasAlertAnalysisFeature());
const autoFixLocked = createMemo(() => !hasAutoFixFeature());
// Auto-fix acknowledgement state (not persisted - must acknowledge each session)
const [autoFixAcknowledged, setAutoFixAcknowledged] = createSignal(false);
@ -239,6 +253,15 @@ export const AISettings: Component = () => {
onMount(() => {
loadSettings();
void (async () => {
try {
const status = await LicenseAPI.getStatus();
setLicenseStatus(status);
} catch (err) {
logger.debug('Failed to load license status for AI gating', err);
setLicenseStatus(null);
}
})();
// Check for OAuth callback parameters in URL
const params = new URLSearchParams(window.location.search);
@ -1220,13 +1243,29 @@ export const AISettings: Component = () => {
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
Alert-Triggered Analysis
<span class="px-1 py-0.5 text-[9px] font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">Efficient</span>
<Show when={alertAnalysisLocked()}>
<span class="px-1 py-0.5 text-[9px] font-semibold bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded">Pro</span>
</Show>
</label>
<Toggle
checked={form.alertTriggeredAnalysis}
onChange={(event) => setForm('alertTriggeredAnalysis', event.currentTarget.checked)}
disabled={saving()}
disabled={saving() || alertAnalysisLocked()}
/>
</div>
<Show when={alertAnalysisLocked()}>
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-1">
Pulse Pro required for alert-triggered analysis.{' '}
<a
class="underline decoration-dotted"
href="https://pulsemonitor.app/pro"
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
</p>
</Show>
{/* Auto-Fix Toggle - Compact with inline warning */}
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
@ -1234,12 +1273,15 @@ export const AISettings: Component = () => {
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 flex items-center gap-1.5">
Auto-Fix Mode
<span class="px-1 py-0.5 text-[9px] font-medium bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded">Advanced</span>
<Show when={autoFixLocked()}>
<span class="px-1 py-0.5 text-[9px] font-semibold bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded">Pro</span>
</Show>
</label>
<Show when={autoFixAcknowledged() || form.patrolAutoFix}>
<Toggle
checked={form.patrolAutoFix}
onChange={(event) => setForm('patrolAutoFix', event.currentTarget.checked)}
disabled={saving()}
disabled={saving() || autoFixLocked()}
/>
</Show>
<Show when={!autoFixAcknowledged() && !form.patrolAutoFix}>
@ -1249,19 +1291,32 @@ export const AISettings: Component = () => {
setAutoFixAcknowledged(true);
setForm('patrolAutoFix', true);
}}
disabled={saving()}
class="px-2 py-1 text-xs bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded hover:bg-amber-200 dark:hover:bg-amber-800"
disabled={saving() || autoFixLocked()}
class="px-2 py-1 text-xs bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300 rounded hover:bg-amber-200 dark:hover:bg-amber-800 disabled:opacity-60 disabled:cursor-not-allowed"
>
Enable
</button>
</Show>
</div>
<Show when={!form.patrolAutoFix && !autoFixAcknowledged()}>
<Show when={autoFixLocked()}>
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-1">
Pulse Pro required for auto-fix.{' '}
<a
class="underline decoration-dotted"
href="https://pulsemonitor.app/pro"
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
</p>
</Show>
<Show when={!autoFixLocked() && !form.patrolAutoFix && !autoFixAcknowledged()}>
<p class="text-[10px] text-amber-600 dark:text-amber-400 mt-1">
AI will execute fixes without approval. Enable with caution.
</p>
</Show>
<Show when={form.patrolAutoFix}>
<Show when={!autoFixLocked() && form.patrolAutoFix}>
<p class="text-[10px] text-red-600 dark:text-red-400 mt-1 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
@ -1272,7 +1327,7 @@ export const AISettings: Component = () => {
</div>
{/* Auto-Fix Model - Only when enabled */}
<Show when={form.patrolAutoFix}>
<Show when={form.patrolAutoFix && !autoFixLocked()}>
<div class="flex items-center gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-32 flex-shrink-0">Fix Model</label>
<Show when={availableModels().length > 0} fallback={

View file

@ -17,6 +17,7 @@ import { showSuccess, showError } from '@/utils/toast';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
import { AlertsAPI } from '@/api/alerts';
import { NotificationsAPI, Webhook } from '@/api/notifications';
import { LicenseAPI, type LicenseFeatureStatus } from '@/api/license';
import type { EmailConfig, AppriseConfig } from '@/api/notifications';
import type { HysteresisThreshold } from '@/types/alerts';
import type { Alert, State, VM, Container, DockerHost, DockerContainer, Host } from '@/types/api';
@ -2077,6 +2078,19 @@ function OverviewTab(props: {
const [newRuleResource, setNewRuleResource] = createSignal('');
const [newRuleCategory, setNewRuleCategory] = createSignal('');
const [newRuleDescription, setNewRuleDescription] = createSignal('');
const [licenseFeatures, setLicenseFeatures] = createSignal<LicenseFeatureStatus | null>(null);
const [licenseLoading, setLicenseLoading] = createSignal(false);
const hasAIAlertsFeature = createMemo(() => {
const status = licenseFeatures();
if (!status) return true;
return Boolean(status.features?.['ai_alerts']);
});
const showAIAlertsUpgrade = createMemo(() => {
const status = licenseFeatures();
if (!status) return false;
return !status.features?.['ai_alerts'];
});
const aiAlertsUpgradeURL = createMemo(() => licenseFeatures()?.upgrade_url || 'https://pulsemonitor.app/pro');
// Live streaming state for running patrol
const [expandedLiveStream, setExpandedLiveStream] = createSignal(false);
// Track streaming blocks for sequential display (like AI chat)
@ -2189,6 +2203,23 @@ function OverviewTab(props: {
}
});
const loadLicenseStatus = async () => {
setLicenseLoading(true);
try {
const status = await LicenseAPI.getFeatures();
setLicenseFeatures(status);
} catch (err) {
logger.debug('Failed to load license status for AI alerts gating', err);
setLicenseFeatures(null);
} finally {
setLicenseLoading(false);
}
};
onMount(() => {
void loadLicenseStatus();
});
// Fetch AI data - extracted for reuse
const fetchAiData = async () => {
try {
@ -3364,6 +3395,22 @@ function OverviewTab(props: {
}>
<div>
<SectionHeader title="Active Alerts" size="md" class="mb-3" />
<Show when={showAIAlertsUpgrade()}>
<div class="mb-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<p class="font-medium">AI alert investigation requires Pulse Pro</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Upgrade to unlock one-click AI analysis for active alerts.
</p>
<a
class="inline-flex items-center gap-1 mt-2 text-xs font-medium text-amber-800 dark:text-amber-200 hover:underline"
href={aiAlertsUpgradeURL()}
target="_blank"
rel="noreferrer"
>
View Pulse Pro plans
</a>
</div>
</Show>
<Show
when={Object.keys(props.activeAlerts).length > 0}
fallback={
@ -3602,6 +3649,7 @@ function OverviewTab(props: {
alert={alert}
variant="text"
size="sm"
licenseLocked={!hasAIAlertsFeature() && !licenseLoading()}
/>
</div>
</div>
@ -5105,6 +5153,13 @@ function ScheduleTab(props: ScheduleTabProps) {
// History Tab - Comprehensive alert table
function HistoryTab() {
const { state, activeAlerts } = useWebSocket();
const [licenseFeatures, setLicenseFeatures] = createSignal<LicenseFeatureStatus | null>(null);
const [licenseLoading, setLicenseLoading] = createSignal(false);
const hasAIAlertsFeature = createMemo(() => {
const status = licenseFeatures();
if (!status) return true;
return Boolean(status.features?.['ai_alerts']);
});
// Filter states with localStorage persistence
const [timeFilter, setTimeFilter] = usePersistentSignal<'24h' | '7d' | '30d' | 'all'>(
@ -5139,6 +5194,21 @@ function HistoryTab() {
(typeof navigator !== 'undefined' ? navigator.language : undefined) ||
'en-US';
onMount(() => {
void (async () => {
setLicenseLoading(true);
try {
const status = await LicenseAPI.getFeatures();
setLicenseFeatures(status);
} catch (err) {
logger.debug('Failed to load license status for AI alerts gating', err);
setLicenseFeatures(null);
} finally {
setLicenseLoading(false);
}
})();
});
const buildHistoryParams = (range: string) => {
const params: { limit?: number; startTime?: string } = {};
const now = Date.now();
@ -6313,6 +6383,7 @@ function HistoryTab() {
}}
variant="icon"
size="sm"
licenseLocked={!hasAIAlertsFeature() && !licenseLoading()}
/>
</Show>
</td>

View file

@ -42,22 +42,17 @@ func (s *Service) SetResourceURL(resourceType, resourceID, customURL string) err
// Validate and normalize the URL
if customURL != "" {
// If no scheme, default to http
if !strings.Contains(customURL, "://") {
customURL = "http://" + customURL
}
// Try to parse the URL
parsedURL, err := url.Parse(customURL)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
// Ensure scheme is present
if parsedURL.Scheme == "" {
// Default to http if no scheme provided
customURL = "http://" + customURL
parsedURL, err = url.Parse(customURL)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
}
// Validate scheme
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("URL must use http:// or https:// scheme")

View file

@ -8,6 +8,8 @@ import (
"net"
"net/http"
"net/url"
"os"
"reflect"
"regexp"
"strconv"
"strings"
@ -30,6 +32,11 @@ type StateProvider interface {
GetState() models.StateSnapshot
}
// CommandPolicy defines the interface for command security policy
type CommandPolicy interface {
Evaluate(command string) agentexec.PolicyDecision
}
// LicenseState represents the current state of the license
type LicenseState string
@ -48,14 +55,20 @@ type LicenseChecker interface {
GetLicenseStateString() (string, bool)
}
// AgentServer defines the interface for communicating with agents
type AgentServer interface {
GetConnectedAgents() []agentexec.ConnectedAgent
ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error)
}
// Service orchestrates AI interactions
type Service struct {
mu sync.RWMutex
persistence *config.ConfigPersistence
provider providers.Provider
cfg *config.AIConfig
agentServer *agentexec.Server
policy *agentexec.CommandPolicy
agentServer AgentServer
policy CommandPolicy
stateProvider StateProvider
alertProvider AlertProvider
knowledgeStore *knowledge.Store
@ -89,7 +102,7 @@ type modelsCache struct {
}
// NewService creates a new AI service
func NewService(persistence *config.ConfigPersistence, agentServer *agentexec.Server) *Service {
func NewService(persistence *config.ConfigPersistence, agentServer AgentServer) *Service {
// Initialize knowledge store
var knowledgeStore *knowledge.Store
costStore := cost.NewStore(cost.DefaultMaxDays)
@ -1693,9 +1706,17 @@ func (s *Service) logRemediation(req ExecuteRequest, command, output string, suc
// This uses the same routing logic as command execution to determine if the target
// can be reached, including cluster peer routing for Proxmox clusters.
func (s *Service) hasAgentForTarget(req ExecuteRequest) bool {
// Check for nil interface or nil underlying value
// Note: A typed nil pointer assigned to an interface is NOT nil according to Go's == operator
// We need to use reflection to check if the underlying value is nil
if s.agentServer == nil {
return false
}
// Check if the interface contains a typed nil pointer
v := reflect.ValueOf(s.agentServer)
if v.Kind() == reflect.Ptr && v.IsNil() {
return false
}
agents := s.agentServer.GetConnectedAgents()
if len(agents) == 0 {
@ -2155,10 +2176,13 @@ func parseAndValidateFetchURL(ctx context.Context, urlStr string) (*url.URL, err
return nil, fmt.Errorf("url is required")
}
parsed, err := url.ParseRequestURI(clean)
parsed, err := url.Parse(clean)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if !parsed.IsAbs() {
return nil, fmt.Errorf("URL must be absolute")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("only http/https URLs are allowed")
}
@ -2212,6 +2236,9 @@ func isBlockedFetchIP(ip net.IP) bool {
return true
}
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
if ip.IsLoopback() && os.Getenv("PULSE_AI_ALLOW_LOOPBACK") == "true" {
return false
}
return true
}
// Block multicast and other non-unicast targets.

View file

@ -67,6 +67,37 @@ func (h *LicenseHandlers) HandleLicenseStatus(w http.ResponseWriter, r *http.Req
json.NewEncoder(w).Encode(status)
}
// LicenseFeaturesResponse provides a minimal, non-admin license view for feature gating.
type LicenseFeaturesResponse struct {
LicenseStatus string `json:"license_status"`
Features map[string]bool `json:"features"`
UpgradeURL string `json:"upgrade_url"`
}
// HandleLicenseFeatures handles GET /api/license/features
// Returns license state and feature availability for authenticated users.
func (h *LicenseHandlers) HandleLicenseFeatures(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
state, _ := h.service.GetLicenseState()
response := LicenseFeaturesResponse{
LicenseStatus: string(state),
Features: map[string]bool{
license.FeatureAIPatrol: h.service.HasFeature(license.FeatureAIPatrol),
license.FeatureAIAlerts: h.service.HasFeature(license.FeatureAIAlerts),
license.FeatureAIAutoFix: h.service.HasFeature(license.FeatureAIAutoFix),
license.FeatureKubernetesAI: h.service.HasFeature(license.FeatureKubernetesAI),
},
UpgradeURL: "https://pulsemonitor.app/pro",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// ActivateLicenseRequest is the request body for activating a license.
type ActivateLicenseRequest struct {
LicenseKey string `json:"license_key"`

View file

@ -424,6 +424,7 @@ func (r *Router) setupRoutes() {
// License routes (Pulse Pro)
r.mux.HandleFunc("/api/license/status", RequireAdmin(r.config, r.licenseHandlers.HandleLicenseStatus))
r.mux.HandleFunc("/api/license/features", RequireAuth(r.config, r.licenseHandlers.HandleLicenseFeatures))
r.mux.HandleFunc("/api/license/activate", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleActivateLicense)))
r.mux.HandleFunc("/api/license/clear", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleClearLicense)))

View file

@ -19,6 +19,14 @@ RELEASE_DIR="release"
echo "Building Pulse v${VERSION}..."
# Optional public key embedding for license validation
LICENSE_LDFLAGS=""
if [[ -n "${PULSE_LICENSE_PUBLIC_KEY:-}" ]]; then
LICENSE_LDFLAGS="-X github.com/rcourtman/pulse-go-rewrite/internal/license.EmbeddedPublicKey=${PULSE_LICENSE_PUBLIC_KEY}"
else
echo "Warning: PULSE_LICENSE_PUBLIC_KEY not set; Pulse Pro license activation will fail for release binaries."
fi
# Clean previous builds
rm -rf $BUILD_DIR $RELEASE_DIR
mkdir -p $BUILD_DIR $RELEASE_DIR
@ -100,7 +108,7 @@ for build_name in "${build_order[@]}"; do
# Build backend binary with version info
env $build_env go build \
-ldflags="-s -w -X main.Version=v${VERSION} -X main.BuildTime=${build_time} -X main.GitCommit=${git_commit} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=v${VERSION}" \
-ldflags="-s -w -X main.Version=v${VERSION} -X main.BuildTime=${build_time} -X main.GitCommit=${git_commit} -X github.com/rcourtman/pulse-go-rewrite/internal/dockeragent.Version=v${VERSION} ${LICENSE_LDFLAGS}" \
-trimpath \
-o "$BUILD_DIR/pulse-$build_name" \
./cmd/pulse