File size: 6,022 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import type { AirtrailFlight } from '@trek/shared';
import { db } from '../../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import { checkSsrf } from '../../utils/ssrfGuard';
import { writeAudit } from '../auditLog';
import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient';
import { normalizeFlight } from './airtrailMapper';

const KEY_MASK = '••••••••';

interface UserConnRow {
  airtrail_url?: string | null;
  airtrail_api_key?: string | null;
  airtrail_allow_insecure_tls?: number | null;
}

function readRow(userId: number): UserConnRow | undefined {
  return db
    .prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
    .get(userId) as UserConnRow | undefined;
}

/** Decrypted creds for outbound calls, or null when the user has no connection. */
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
  const row = readRow(userId);
  if (!row?.airtrail_url || !row?.airtrail_api_key) return null;
  const apiKey = decrypt_api_key(row.airtrail_api_key);
  if (!apiKey) return null;
  return {
    baseUrl: row.airtrail_url,
    apiKey,
    allowInsecureTls: !!row.airtrail_allow_insecure_tls,
  };
}

/** Settings as shown in the UI — the key is never echoed, only masked. */
export function getConnectionSettings(userId: number) {
  const row = readRow(userId);
  return {
    url: row?.airtrail_url || '',
    apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
    allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
    connected: !!(row?.airtrail_url && row?.airtrail_api_key),
  };
}

export async function saveSettings(
  userId: number,
  url: string | undefined,
  apiKey: string | undefined,
  allowInsecureTls: boolean,
  clientIp: string | null,
): Promise<{ success: boolean; warning?: string; error?: string }> {
  const trimmedUrl = (url || '').trim();
  let warning: string | undefined;

  if (trimmedUrl) {
    const ssrf = await checkSsrf(trimmedUrl);
    // Reject only genuinely unusable URLs (malformed, unresolvable, non-http,
    // loopback). Private/LAN instances are the common self-hosted case, so we
    // persist them with a warning rather than blocking — the outbound calls
    // still need ALLOW_INTERNAL_NETWORK=true to actually reach them.
    if (!ssrf.allowed && !ssrf.isPrivate) {
      return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
    }
    if (ssrf.isPrivate) {
      writeAudit({
        userId,
        action: 'airtrail.private_ip_configured',
        ip: clientIp,
        details: { airtrail_url: trimmedUrl, resolved_ip: ssrf.resolvedIp },
      });
      warning = `AirTrail URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`;
    }
  }

  // Only overwrite the stored key when a genuinely new value is supplied;
  // a blank field or the mask means "keep the existing key".
  const provided = (apiKey || '').trim();
  const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;

  if (newKey !== undefined) {
    db.prepare(
      'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
    ).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
  } else {
    db.prepare(
      'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
    ).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
    // Clearing the URL with no key left makes the connection meaningless — drop the key too.
    if (!trimmedUrl) {
      db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
    }
  }

  return { success: true, warning };
}

async function probe(creds: AirtrailCreds): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
  try {
    const flights = await listFlights(creds);
    return { connected: true, flightCount: flights.length };
  } catch (err: unknown) {
    if (err instanceof AirtrailAuthError) return { connected: false, error: 'Invalid API key' };
    return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
  }
}

/** Live check using the stored connection. */
export async function getConnectionStatus(
  userId: number,
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
  const creds = getAirtrailCredentials(userId);
  if (!creds) return { connected: false, error: 'Not configured' };
  return probe(creds);
}

/**
 * "Test connection" from the settings form. Uses the typed URL/key when given;
 * falls back to the stored key when the key field still shows the mask.
 */
export async function testConnection(
  userId: number,
  url: string | undefined,
  apiKey: string | undefined,
  allowInsecureTls: boolean,
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
  const trimmedUrl = (url || '').trim();
  const provided = (apiKey || '').trim();

  const stored = getAirtrailCredentials(userId);
  const effectiveUrl = trimmedUrl || stored?.baseUrl;
  const effectiveKey = provided && provided !== KEY_MASK ? provided : stored?.apiKey;

  if (!effectiveUrl || !effectiveKey) {
    return { connected: false, error: 'URL and API key required' };
  }

  const ssrf = await checkSsrf(effectiveUrl);
  if (!ssrf.allowed && !ssrf.isPrivate) {
    return { connected: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
  }

  return probe({ baseUrl: effectiveUrl, apiKey: effectiveKey, allowInsecureTls });
}

/** The user's AirTrail flights, normalized for the import picker. */
export async function getFlightsForPicker(userId: number): Promise<AirtrailFlight[]> {
  const creds = getAirtrailCredentials(userId);
  if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
  const raw = await listFlights(creds);
  return raw.map(normalizeFlight);
}