mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(secrets): GUI-managed .env secrets store — keys are config, but gitignored (#47)
API keys/secrets now live in /data/secrets/.env (gitignored, never in config
YAML), while remaining fully editable from the dashboard. Config YAML holds
only ${VAR} references.
Backend:
- meshai/secrets_store.py: get_status (SET/NOT-SET, never values), set_secret,
delete_secret over /data/secrets/.env (resolved like load_config); authoritative
SECRET_FIELD_TO_ENV map (traffic→TOMTOM_API_KEY, firms→FIRMS_MAP_KEY,
roads511→ROADS511_API_KEY, wzdx→WZDX_API_KEY, smtp→SMTP_PASSWORD,
mesh_sources→MESHMONITOR_API_TOKEN) + backend-dependent llm_env_var
- dashboard/api/secrets_routes.py: GET /api/secrets (status only), PUT/DELETE
/api/secrets/{env_var} (validated, restart_required); registered in server.py
- config_loader: save_section preserves ${VAR} secret refs on section save
(never rejects them); EXPECTED_SECRETS += ROADS511_API_KEY, WZDX_API_KEY
- config.example.yaml + docker-entrypoint default config use ${VAR} refs;
first-run bootstraps /data/secrets/.env; .gitignore covers it
Frontend:
- components/ManagedSecret.tsx: masked, Set/Not-set badge, reveal, Save->PUT,
"restart required"; carries no config value so secrets never enter a section
save payload
- wired into Environment (tomtom/roads511/wzdx/firms), Config LLM tab
(env var by backend), Notifications (smtp)
Restart required after a secret change (env read at config-load). 11 store
tests; suite at 10-failure baseline (1714 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a502778990
commit
3495eb31de
13 changed files with 477 additions and 21 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -78,3 +78,8 @@ local.yaml
|
||||||
data/*.sqlite
|
data/*.sqlite
|
||||||
data/*.sqlite-wal
|
data/*.sqlite-wal
|
||||||
data/*.sqlite-shm
|
data/*.sqlite-shm
|
||||||
|
|
||||||
|
# Secrets (values live here, never committed)
|
||||||
|
/data/secrets/.env
|
||||||
|
data/secrets/.env
|
||||||
|
work/data/secrets/.env
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ context:
|
||||||
# === LLM BACKEND ===
|
# === LLM BACKEND ===
|
||||||
llm:
|
llm:
|
||||||
backend: openai # openai | anthropic | google
|
backend: openai # openai | anthropic | google
|
||||||
api_key: "" # API key (or use LLM_API_KEY env var)
|
api_key: ${GOOGLE_API_KEY} # Secret: set via dashboard or /data/secrets/.env (var depends on backend: GOOGLE_API_KEY/OPENAI_API_KEY/ANTHROPIC_API_KEY)
|
||||||
base_url: https://api.openai.com/v1 # API base URL
|
base_url: https://api.openai.com/v1 # API base URL
|
||||||
model: gpt-4o-mini # Model name
|
model: gpt-4o-mini # Model name
|
||||||
timeout: 30 # Request timeout (seconds)
|
timeout: 30 # Request timeout (seconds)
|
||||||
|
|
@ -184,7 +184,7 @@ environmental:
|
||||||
traffic:
|
traffic:
|
||||||
enabled: false
|
enabled: false
|
||||||
tick_seconds: 300
|
tick_seconds: 300
|
||||||
api_key: "" # Get key at developer.tomtom.com
|
api_key: ${TOMTOM_API_KEY} # Secret: set via dashboard or /data/secrets/.env (get key at developer.tomtom.com)
|
||||||
corridors: []
|
corridors: []
|
||||||
# Example corridors:
|
# Example corridors:
|
||||||
# - name: "I-84 Twin Falls"
|
# - name: "I-84 Twin Falls"
|
||||||
|
|
@ -195,7 +195,7 @@ environmental:
|
||||||
roads511:
|
roads511:
|
||||||
enabled: false
|
enabled: false
|
||||||
tick_seconds: 300
|
tick_seconds: 300
|
||||||
api_key: ""
|
api_key: ${ROADS511_API_KEY} # Secret: set via dashboard or /data/secrets/.env (leave var empty if 511 needs no key)
|
||||||
base_url: "" # e.g. "https://511.idaho.gov/api/v2"
|
base_url: "" # e.g. "https://511.idaho.gov/api/v2"
|
||||||
endpoints: ["/get/event"]
|
endpoints: ["/get/event"]
|
||||||
bbox: [] # [west, south, east, north]
|
bbox: [] # [west, south, east, north]
|
||||||
|
|
@ -206,7 +206,7 @@ environmental:
|
||||||
firms:
|
firms:
|
||||||
enabled: false
|
enabled: false
|
||||||
tick_seconds: 1800 # 30 min default
|
tick_seconds: 1800 # 30 min default
|
||||||
map_key: "" # Required - NASA FIRMS MAP_KEY
|
map_key: ${FIRMS_MAP_KEY} # Secret: set via dashboard or /data/secrets/.env (NASA FIRMS MAP_KEY)
|
||||||
source: "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
|
source: "VIIRS_SNPP_NRT" # VIIRS_SNPP_NRT, VIIRS_NOAA20_NRT, MODIS_NRT
|
||||||
bbox: [] # [west, south, east, north] - Required
|
bbox: [] # [west, south, east, north] - Required
|
||||||
day_range: 1 # 1-10 days of data
|
day_range: 1 # 1-10 days of data
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ MQTT_PASSWORD=
|
||||||
# Environmental Feed Keys
|
# Environmental Feed Keys
|
||||||
TOMTOM_API_KEY=
|
TOMTOM_API_KEY=
|
||||||
FIRMS_MAP_KEY=
|
FIRMS_MAP_KEY=
|
||||||
|
ROADS511_API_KEY=
|
||||||
|
WZDX_API_KEY=
|
||||||
|
|
||||||
# Notification Credentials
|
# Notification Credentials
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
|
|
|
||||||
117
work/dashboard-frontend/src/components/ManagedSecret.tsx
Normal file
117
work/dashboard-frontend/src/components/ManagedSecret.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SecretEntry {
|
||||||
|
env_var: string
|
||||||
|
is_set: boolean
|
||||||
|
fields?: string[]
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManagedSecret({ envVar, label = 'API Key', helper = '', info: _info = '' }: {
|
||||||
|
envVar: string
|
||||||
|
label?: string
|
||||||
|
helper?: string
|
||||||
|
info?: string
|
||||||
|
}) {
|
||||||
|
const [isSet, setIsSet] = useState<boolean | null>(null) // null = loading
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [restartMsg, setRestartMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/secrets')
|
||||||
|
if (res.ok) {
|
||||||
|
const data: SecretEntry[] = await res.json()
|
||||||
|
const entry = data.find((e) => e.env_var === envVar)
|
||||||
|
setIsSet(entry ? entry.is_set : false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsSet(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [envVar])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!value.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
setRestartMsg('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/secrets/' + encodeURIComponent(envVar), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setValue('')
|
||||||
|
setShow(false)
|
||||||
|
setRestartMsg('Saved — restart required to take effect')
|
||||||
|
await fetchStatus()
|
||||||
|
} else {
|
||||||
|
const body = await res.text()
|
||||||
|
setError('Save failed: ' + (body || String(res.status)))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Save failed: network error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = isSet
|
||||||
|
? 'env set — enter a new value to change'
|
||||||
|
: 'not set — enter a value'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
{isSet === null ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded ml-2 bg-slate-800 text-slate-500">Loading</span>
|
||||||
|
) : isSet ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded ml-2 bg-green-500/10 text-green-400">Set</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded ml-2 bg-slate-800 text-slate-500">Not set</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent placeholder-slate-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShow(!show)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!value.trim() || saving}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||||
|
<p className="text-xs text-slate-600 font-mono">{envVar}</p>
|
||||||
|
{restartMsg && <p className="text-xs text-yellow-400">{restartMsg}</p>}
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||||
|
import { ManagedSecret } from '@/components/ManagedSecret'
|
||||||
import { useDirty } from '@/context/DirtyContext'
|
import { useDirty } from '@/context/DirtyContext'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
import ChannelPicker from '@/components/ChannelPicker'
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
|
|
@ -1011,6 +1012,7 @@ function CommandsSection({ data, onChange }: { data: CommandsConfig; onChange: (
|
||||||
}
|
}
|
||||||
|
|
||||||
function LLMSection({ data, onChange }: { data: LLMConfig; onChange: (d: LLMConfig) => void }) {
|
function LLMSection({ data, onChange }: { data: LLMConfig; onChange: (d: LLMConfig) => void }) {
|
||||||
|
const llmEnvVar = ({ openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', google: 'GOOGLE_API_KEY' } as Record<string, string>)[(data.backend || '').toLowerCase()] || 'LLM_API_KEY'
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SectionDescription text={SECTION_DESCRIPTIONS.llm} />
|
<SectionDescription text={SECTION_DESCRIPTIONS.llm} />
|
||||||
|
|
@ -1036,13 +1038,10 @@ function LLMSection({ data, onChange }: { data: LLMConfig; onChange: (d: LLMConf
|
||||||
info="The specific model to use. Common choices: gpt-4o-mini (fast, cheap), gpt-4o (better, costs more), claude-sonnet-4-20250514 (Anthropic equivalent). For local models via Ollama, use the model name you pulled (e.g. llama3.1)."
|
info="The specific model to use. Common choices: gpt-4o-mini (fast, cheap), gpt-4o (better, costs more), claude-sonnet-4-20250514 (Anthropic equivalent). For local models via Ollama, use the model name you pulled (e.g. llama3.1)."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TextInput
|
<ManagedSecret
|
||||||
|
envVar={llmEnvVar}
|
||||||
label="API Key"
|
label="API Key"
|
||||||
value={data.api_key}
|
helper="Secret stored in /data/secrets/.env; config holds the ${VAR} ref"
|
||||||
onChange={(v) => onChange({ ...data, api_key: v })}
|
|
||||||
type="password"
|
|
||||||
helper="Supports ${ENV_VAR} syntax"
|
|
||||||
info="Your API key from the provider. You can also use ${ENV_VAR} syntax to read from an environment variable instead of storing the key in the config file."
|
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Base URL"
|
label="Base URL"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
import { TOGGLE_FAMILY_META, type NotificationToggle, type NotificationsConfig } from './Notifications'
|
import { TOGGLE_FAMILY_META, type NotificationToggle, type NotificationsConfig } from './Notifications'
|
||||||
import AdapterConfig, { CURATED_KEYS } from './AdapterConfig'
|
import AdapterConfig, { CURATED_KEYS } from './AdapterConfig'
|
||||||
|
import { ManagedSecret } from '@/components/ManagedSecret'
|
||||||
|
|
||||||
type FeedSource = 'native' | 'central'
|
type FeedSource = 'native' | 'central'
|
||||||
|
|
||||||
|
|
@ -1153,7 +1154,7 @@ const save = async () => {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
case 'traffic': return (<>
|
case 'traffic': return (<>
|
||||||
<TextInput label="API Key" value={env.traffic.api_key} onChange={(v) => up({ traffic: { ...env.traffic, api_key: v } })} type="password" helper="developer.tomtom.com" />
|
<ManagedSecret envVar="TOMTOM_API_KEY" label="API Key" helper="developer.tomtom.com" />
|
||||||
<NumberInput label="Tick Seconds" value={env.traffic.tick_seconds} onChange={(v) => up({ traffic: { ...env.traffic, tick_seconds: v } })} min={60} />
|
<NumberInput label="Tick Seconds" value={env.traffic.tick_seconds} onChange={(v) => up({ traffic: { ...env.traffic, tick_seconds: v } })} min={60} />
|
||||||
<div className="text-xs text-[#666] mt-2">Corridors:</div>
|
<div className="text-xs text-[#666] mt-2">Corridors:</div>
|
||||||
{(env.traffic.corridors || []).map((c, i) => (
|
{(env.traffic.corridors || []).map((c, i) => (
|
||||||
|
|
@ -1197,7 +1198,7 @@ const save = async () => {
|
||||||
</>)
|
</>)
|
||||||
case 'roads511': return (<>
|
case 'roads511': return (<>
|
||||||
<TextInput label="Base URL" value={env.roads511.base_url} onChange={(v) => up({ roads511: { ...env.roads511, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" />
|
<TextInput label="Base URL" value={env.roads511.base_url} onChange={(v) => up({ roads511: { ...env.roads511, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" />
|
||||||
<TextInput label="API Key" value={env.roads511.api_key} onChange={(v) => up({ roads511: { ...env.roads511, api_key: v } })} type="password" helper="Leave empty if not required" />
|
<ManagedSecret envVar="ROADS511_API_KEY" label="API Key" helper="Leave unset if 511 needs no key" />
|
||||||
<NumberInput label="Tick Seconds" value={env.roads511.tick_seconds} onChange={(v) => up({ roads511: { ...env.roads511, tick_seconds: v } })} min={60} />
|
<NumberInput label="Tick Seconds" value={env.roads511.tick_seconds} onChange={(v) => up({ roads511: { ...env.roads511, tick_seconds: v } })} min={60} />
|
||||||
<ListInput label="Endpoints" value={env.roads511.endpoints} onChange={(v) => up({ roads511: { ...env.roads511, endpoints: v } })} helper="e.g., /get/event" />
|
<ListInput label="Endpoints" value={env.roads511.endpoints} onChange={(v) => up({ roads511: { ...env.roads511, endpoints: v } })} helper="e.g., /get/event" />
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
|
@ -1254,7 +1255,7 @@ const save = async () => {
|
||||||
{env.wzdx?.feed_source !== 'central' && (
|
{env.wzdx?.feed_source !== 'central' && (
|
||||||
<>
|
<>
|
||||||
<TextInput label="Base URL" value={env.wzdx?.base_url ?? ''} onChange={(v) => up({ wzdx: { ...env.wzdx!, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" />
|
<TextInput label="Base URL" value={env.wzdx?.base_url ?? ''} onChange={(v) => up({ wzdx: { ...env.wzdx!, base_url: v } })} placeholder="https://511.yourstate.gov/api/v2" />
|
||||||
<TextInput label="API Key" value={env.wzdx?.api_key ?? ''} onChange={(v) => up({ wzdx: { ...env.wzdx!, api_key: v } })} type="password" helper="Leave empty if not required" />
|
<ManagedSecret envVar="WZDX_API_KEY" label="API Key" helper="Leave unset if not required" />
|
||||||
<NumberInput label="Tick Seconds" value={env.wzdx?.tick_seconds ?? 300} onChange={(v) => up({ wzdx: { ...env.wzdx!, tick_seconds: v } })} min={60} />
|
<NumberInput label="Tick Seconds" value={env.wzdx?.tick_seconds ?? 300} onChange={(v) => up({ wzdx: { ...env.wzdx!, tick_seconds: v } })} min={60} />
|
||||||
<ListInput label="Endpoints" value={env.wzdx?.endpoints ?? ['/get/event']} onChange={(v) => up({ wzdx: { ...env.wzdx!, endpoints: v } })} helper="e.g., /get/event" />
|
<ListInput label="Endpoints" value={env.wzdx?.endpoints ?? ['/get/event']} onChange={(v) => up({ wzdx: { ...env.wzdx!, endpoints: v } })} helper="e.g., /get/event" />
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
|
@ -1312,7 +1313,7 @@ const save = async () => {
|
||||||
</div>
|
</div>
|
||||||
</>)
|
</>)
|
||||||
case 'firms': return (<>
|
case 'firms': return (<>
|
||||||
<TextInput label="MAP Key" value={env.firms.map_key} onChange={(v) => up({ firms: { ...env.firms, map_key: v } })} type="password" helper="firms.modaps.eosdis.nasa.gov/api/area/" infoLink="https://firms.modaps.eosdis.nasa.gov/api/area/" />
|
<ManagedSecret envVar="FIRMS_MAP_KEY" label="MAP Key" helper="NASA FIRMS MAP_KEY" />
|
||||||
<NumberInput label="Tick Seconds" value={env.firms.tick_seconds} onChange={(v) => up({ firms: { ...env.firms, tick_seconds: v } })} min={300} />
|
<NumberInput label="Tick Seconds" value={env.firms.tick_seconds} onChange={(v) => up({ firms: { ...env.firms, tick_seconds: v } })} min={300} />
|
||||||
<SelectInput label="Satellite Source" value={env.firms.source} onChange={(v) => up({ firms: { ...env.firms, source: v } })} options={[{ value: 'VIIRS_SNPP_NRT', label: 'VIIRS SNPP (NRT)' }, { value: 'VIIRS_NOAA20_NRT', label: 'VIIRS NOAA-20 (NRT)' }, { value: 'MODIS_NRT', label: 'MODIS (NRT)' }]} />
|
<SelectInput label="Satellite Source" value={env.firms.source} onChange={(v) => up({ firms: { ...env.firms, source: v } })} options={[{ value: 'VIIRS_SNPP_NRT', label: 'VIIRS SNPP (NRT)' }, { value: 'VIIRS_NOAA20_NRT', label: 'VIIRS NOAA-20 (NRT)' }, { value: 'MODIS_NRT', label: 'MODIS (NRT)' }]} />
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
import ChannelPicker from '@/components/ChannelPicker'
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
import { useDirty } from '@/context/DirtyContext'
|
import { useDirty } from '@/context/DirtyContext'
|
||||||
|
import { ManagedSecret } from '@/components/ManagedSecret'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface NotificationRuleConfig {
|
interface NotificationRuleConfig {
|
||||||
|
|
@ -1341,12 +1342,10 @@ function NotificationRuleCard({
|
||||||
value={rule.smtp_user || ''}
|
value={rule.smtp_user || ''}
|
||||||
onChange={(v) => onChange({ ...rule, smtp_user: v })}
|
onChange={(v) => onChange({ ...rule, smtp_user: v })}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<ManagedSecret
|
||||||
|
envVar="SMTP_PASSWORD"
|
||||||
label="Password"
|
label="Password"
|
||||||
value={rule.smtp_password || ''}
|
helper="SMTP password (App Password for Gmail)"
|
||||||
onChange={(v) => onChange({ ...rule, smtp_password: v })}
|
|
||||||
type="password"
|
|
||||||
info="Gmail users: use an App Password from myaccount.google.com/apppasswords"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
|
|
@ -1763,7 +1762,7 @@ function OtherChannelsGrid({
|
||||||
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
|
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
|
||||||
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
|
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
|
||||||
<TextInput label="Username" value={t.smtp_user || ''} onChange={(v) => upd(key, { smtp_user: v })} />
|
<TextInput label="Username" value={t.smtp_user || ''} onChange={(v) => upd(key, { smtp_user: v })} />
|
||||||
<TextInput label="Password" value={t.smtp_password || ''} onChange={(v) => upd(key, { smtp_password: v })} type="password" />
|
<ManagedSecret envVar="SMTP_PASSWORD" label="Password" helper="SMTP password (App Password for Gmail)" />
|
||||||
<Toggle label="Use TLS" checked={t.smtp_tls ?? true} onChange={(v) => upd(key, { smtp_tls: v })} />
|
<Toggle label="Use TLS" checked={t.smtp_tls ?? true} onChange={(v) => upd(key, { smtp_tls: v })} />
|
||||||
<TextInput label="From address" value={t.from_address || ''} onChange={(v) => upd(key, { from_address: v })} placeholder="alerts@example.com" />
|
<TextInput label="From address" value={t.from_address || ''} onChange={(v) => upd(key, { from_address: v })} placeholder="alerts@example.com" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ context:
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
backend: openai
|
backend: openai
|
||||||
api_key: ""
|
api_key: ${GOOGLE_API_KEY} # secret: set via dashboard or /data/secrets/.env
|
||||||
base_url: https://api.openai.com/v1
|
base_url: https://api.openai.com/v1
|
||||||
model: gpt-4o-mini
|
model: gpt-4o-mini
|
||||||
timeout: 30
|
timeout: 30
|
||||||
|
|
@ -66,6 +66,8 @@ meshmonitor:
|
||||||
enabled: false
|
enabled: false
|
||||||
inject_into_prompt: true
|
inject_into_prompt: true
|
||||||
EOF
|
EOF
|
||||||
|
mkdir -p /data/secrets
|
||||||
|
[ -f /data/secrets/.env ] || : > /data/secrets/.env
|
||||||
echo "Default config created at $MESHAI_CONFIG. Edit it or configure via the dashboard."
|
echo "Default config created at $MESHAI_CONFIG. Edit it or configure via the dashboard."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,8 @@ EXPECTED_SECRETS: list[str] = [
|
||||||
"MQTT_PASSWORD",
|
"MQTT_PASSWORD",
|
||||||
"TOMTOM_API_KEY",
|
"TOMTOM_API_KEY",
|
||||||
"FIRMS_MAP_KEY",
|
"FIRMS_MAP_KEY",
|
||||||
|
"ROADS511_API_KEY",
|
||||||
|
"WZDX_API_KEY",
|
||||||
"SMTP_PASSWORD",
|
"SMTP_PASSWORD",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -740,6 +742,12 @@ def save_section(
|
||||||
for key, value in d.items():
|
for key, value in d.items():
|
||||||
field_path = f"{path}.{key}" if path else key
|
field_path = f"{path}.{key}" if path else key
|
||||||
if _is_secret_field(section_name, field_path):
|
if _is_secret_field(section_name, field_path):
|
||||||
|
# GUI-managed secrets: config holds only ${VAR} refs. If the
|
||||||
|
# incoming value is itself a ${VAR} reference, always preserve
|
||||||
|
# it (secret values live in /data/secrets/.env, never here).
|
||||||
|
if isinstance(value, str) and _VAR_RE.match(value):
|
||||||
|
cleaned[key] = value
|
||||||
|
continue
|
||||||
ref = _ondisk_ref(field_path)
|
ref = _ondisk_ref(field_path)
|
||||||
m = _VAR_RE.match(ref) if isinstance(ref, str) else None
|
m = _VAR_RE.match(ref) if isinstance(ref, str) else None
|
||||||
if m:
|
if m:
|
||||||
|
|
|
||||||
42
work/meshai/dashboard/api/secrets_routes.py
Normal file
42
work/meshai/dashboard/api/secrets_routes.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Secrets management API routes.
|
||||||
|
|
||||||
|
Secret VALUES never appear in responses or logs — only set/delete/status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from meshai import secrets_store
|
||||||
|
|
||||||
|
router = APIRouter(tags=["secrets"])
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SecretUpdate(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/secrets")
|
||||||
|
async def list_secrets():
|
||||||
|
"""List all managed secrets with set/unset status (no values)."""
|
||||||
|
return secrets_store.list_secrets()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/secrets/{env_var}")
|
||||||
|
async def set_secret(env_var: str, body: SecretUpdate):
|
||||||
|
"""Set a managed secret value."""
|
||||||
|
if env_var not in secrets_store.SECRET_LABELS:
|
||||||
|
raise HTTPException(status_code=400, detail="unknown secret var")
|
||||||
|
secrets_store.set_secret(env_var, body.value)
|
||||||
|
return {"ok": True, "restart_required": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/secrets/{env_var}")
|
||||||
|
async def delete_secret(env_var: str):
|
||||||
|
"""Delete a managed secret from the .env file."""
|
||||||
|
if env_var not in secrets_store.SECRET_LABELS:
|
||||||
|
raise HTTPException(status_code=400, detail="unknown secret var")
|
||||||
|
secrets_store.delete_secret(env_var)
|
||||||
|
return {"ok": True, "restart_required": True}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""FastAPI server for MeshAI dashboard."""
|
"""FastAPI server for MeshAI dashboard."""
|
||||||
from meshai.dashboard.api.adapter_config_routes import router as adapter_config_router
|
from meshai.dashboard.api.adapter_config_routes import router as adapter_config_router
|
||||||
from meshai.dashboard.api.curation_routes import router as curation_router
|
from meshai.dashboard.api.curation_routes import router as curation_router
|
||||||
|
from meshai.dashboard.api.secrets_routes import router as secrets_router
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -68,6 +69,7 @@ def create_app() -> FastAPI:
|
||||||
app.include_router(alert_router, prefix="/api")
|
app.include_router(alert_router, prefix="/api")
|
||||||
|
|
||||||
app.include_router(notification_router, prefix="/api")
|
app.include_router(notification_router, prefix="/api")
|
||||||
|
app.include_router(secrets_router, prefix="/api")
|
||||||
app.include_router(debug_router, prefix="/api")
|
app.include_router(debug_router, prefix="/api")
|
||||||
# WebSocket router (no prefix, path is /ws/live)
|
# WebSocket router (no prefix, path is /ws/live)
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
|
||||||
142
work/meshai/secrets_store.py
Normal file
142
work/meshai/secrets_store.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""GUI-managed secrets store for MeshAI.
|
||||||
|
|
||||||
|
Secret VALUES live in a .env file (default /data/secrets/.env).
|
||||||
|
Config YAML holds only ${VAR} references — never raw values.
|
||||||
|
This module NEVER returns or logs secret values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import set_key, unset_key, dotenv_values
|
||||||
|
|
||||||
|
|
||||||
|
def secret_env_path(config_dir: Path = Path("/data/config")) -> Path:
|
||||||
|
"""Resolve the secrets .env path the same way load_config does; ensure parent dir exists."""
|
||||||
|
p = Path(config_dir).parent / "secrets" / ".env"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# config dotted-field -> env var name. llm.api_key is backend-dependent (see llm_env_var).
|
||||||
|
SECRET_FIELD_TO_ENV: dict[str, str] = {
|
||||||
|
"environmental.traffic.api_key": "TOMTOM_API_KEY",
|
||||||
|
"environmental.firms.map_key": "FIRMS_MAP_KEY",
|
||||||
|
"environmental.roads511.api_key": "ROADS511_API_KEY",
|
||||||
|
"environmental.wzdx.api_key": "WZDX_API_KEY",
|
||||||
|
"notifications.toggles.*.smtp_password": "SMTP_PASSWORD",
|
||||||
|
"notifications.rules.*.smtp_password": "SMTP_PASSWORD",
|
||||||
|
"mesh_sources.*.api_token": "MESHMONITOR_API_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
# llm.api_key backend -> env var (mirrors config_loader.py backend dict; else LLM_API_KEY)
|
||||||
|
LLM_BACKEND_TO_ENV: dict[str, str] = {
|
||||||
|
"openai": "OPENAI_API_KEY",
|
||||||
|
"anthropic": "ANTHROPIC_API_KEY",
|
||||||
|
"google": "GOOGLE_API_KEY",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def llm_env_var(backend: str | None) -> str:
|
||||||
|
return LLM_BACKEND_TO_ENV.get((backend or "").lower(), "LLM_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
# Human labels for every managed env var.
|
||||||
|
SECRET_LABELS: dict[str, str] = {
|
||||||
|
"GOOGLE_API_KEY": "Google (Gemini) API key",
|
||||||
|
"OPENAI_API_KEY": "OpenAI API key",
|
||||||
|
"ANTHROPIC_API_KEY": "Anthropic API key",
|
||||||
|
"LLM_API_KEY": "LLM API key (generic)",
|
||||||
|
"TOMTOM_API_KEY": "TomTom API key",
|
||||||
|
"FIRMS_MAP_KEY": "NASA FIRMS MAP_KEY",
|
||||||
|
"ROADS511_API_KEY": "511 Roads API key",
|
||||||
|
"WZDX_API_KEY": "WZDx API key",
|
||||||
|
"SMTP_PASSWORD": "SMTP password",
|
||||||
|
"MESHMONITOR_API_TOKEN": "MeshMonitor API token",
|
||||||
|
"MQTT_PASSWORD": "MQTT password",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_vars() -> list[str]:
|
||||||
|
"""Return list of managed env var names (stable insertion order)."""
|
||||||
|
return list(SECRET_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_status(config_dir: Path = Path("/data/config")) -> dict[str, bool]:
|
||||||
|
"""For each managed var, True if set (non-empty) in .env OR os.environ.
|
||||||
|
|
||||||
|
NEVER returns values — only booleans.
|
||||||
|
"""
|
||||||
|
path = secret_env_path(config_dir)
|
||||||
|
try:
|
||||||
|
env_file = dotenv_values(path) if path.exists() else {}
|
||||||
|
except Exception:
|
||||||
|
env_file = {}
|
||||||
|
|
||||||
|
result: dict[str, bool] = {}
|
||||||
|
for var in _managed_vars():
|
||||||
|
file_val = env_file.get(var, "")
|
||||||
|
env_val = os.environ.get(var, "")
|
||||||
|
result[var] = bool(file_val) or bool(env_val)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def set_secret(var: str, value: str, config_dir: Path = Path("/data/config")) -> None:
|
||||||
|
"""Write var=value into the secrets .env file.
|
||||||
|
|
||||||
|
Raises ValueError if var is not a managed secret.
|
||||||
|
"""
|
||||||
|
if var not in SECRET_LABELS:
|
||||||
|
raise ValueError(f"Unknown secret var: {var!r}. Allowed: {list(SECRET_LABELS)}")
|
||||||
|
path = secret_env_path(config_dir)
|
||||||
|
set_key(str(path), var, value)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_secret(var: str, config_dir: Path = Path("/data/config")) -> None:
|
||||||
|
"""Remove var from the secrets .env file (no-op if file/key missing).
|
||||||
|
|
||||||
|
Raises ValueError if var is not a managed secret.
|
||||||
|
"""
|
||||||
|
if var not in SECRET_LABELS:
|
||||||
|
raise ValueError(f"Unknown secret var: {var!r}. Allowed: {list(SECRET_LABELS)}")
|
||||||
|
path = secret_env_path(config_dir)
|
||||||
|
try:
|
||||||
|
unset_key(str(path), var)
|
||||||
|
except (KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _env_to_fields() -> dict[str, list[str]]:
|
||||||
|
"""Reverse of SECRET_FIELD_TO_ENV: env var -> list of dotted config fields.
|
||||||
|
|
||||||
|
Also includes llm.api_key for all four LLM env vars.
|
||||||
|
"""
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for field, var in SECRET_FIELD_TO_ENV.items():
|
||||||
|
result.setdefault(var, []).append(field)
|
||||||
|
|
||||||
|
# LLM keys all map to llm.api_key
|
||||||
|
for llm_var in ("GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_API_KEY"):
|
||||||
|
result.setdefault(llm_var, []).append("llm.api_key")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_secrets(config_dir: Path = Path("/data/config")) -> list[dict]:
|
||||||
|
"""Build the API payload for GET /secrets.
|
||||||
|
|
||||||
|
Each item: {"env_var": str, "is_set": bool, "fields": list[str], "label": str}.
|
||||||
|
Values are NEVER included.
|
||||||
|
"""
|
||||||
|
status = get_status(config_dir)
|
||||||
|
field_map = _env_to_fields()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"env_var": var,
|
||||||
|
"is_set": status[var],
|
||||||
|
"fields": field_map.get(var, []),
|
||||||
|
"label": SECRET_LABELS[var],
|
||||||
|
}
|
||||||
|
for var in _managed_vars()
|
||||||
|
]
|
||||||
137
work/tests/test_secrets_store.py
Normal file
137
work/tests/test_secrets_store.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""Tests for meshai.secrets_store — no /data access, no env leakage."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meshai import secrets_store
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cfg(tmp_path):
|
||||||
|
"""Return a config_dir under tmp_path (parent gets a sibling secrets/)."""
|
||||||
|
d = tmp_path / "config"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Round-trip: set -> status True -> delete -> status False
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_roundtrip_set_get_delete(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
assert secrets_store.get_status(config_dir=cfg)["TOMTOM_API_KEY"] is False
|
||||||
|
|
||||||
|
secrets_store.set_secret("TOMTOM_API_KEY", "abc", config_dir=cfg)
|
||||||
|
assert secrets_store.get_status(config_dir=cfg)["TOMTOM_API_KEY"] is True
|
||||||
|
|
||||||
|
secrets_store.delete_secret("TOMTOM_API_KEY", config_dir=cfg)
|
||||||
|
assert secrets_store.get_status(config_dir=cfg)["TOMTOM_API_KEY"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unknown var raises ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_set_unknown_raises(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
with pytest.raises(ValueError, match="Unknown secret var"):
|
||||||
|
secrets_store.set_secret("NOPE_KEY", "x", config_dir=cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_unknown_raises(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
with pytest.raises(ValueError, match="Unknown secret var"):
|
||||||
|
secrets_store.delete_secret("NOPE_KEY", config_dir=cfg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Values never leak
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_get_status_no_values(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
secrets_store.set_secret("TOMTOM_API_KEY", "abc", config_dir=cfg)
|
||||||
|
status = secrets_store.get_status(config_dir=cfg)
|
||||||
|
# All values must be booleans
|
||||||
|
for v in status.values():
|
||||||
|
assert isinstance(v, bool), f"Expected bool, got {type(v)}: {v!r}"
|
||||||
|
# The literal secret value must not appear anywhere
|
||||||
|
assert "abc" not in str(status)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_secrets_no_values(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
secrets_store.set_secret("TOMTOM_API_KEY", "abc", config_dir=cfg)
|
||||||
|
items = secrets_store.list_secrets(config_dir=cfg)
|
||||||
|
dumped = json.dumps(items)
|
||||||
|
assert "abc" not in dumped, "Secret value leaked into list_secrets output"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# list_secrets shape
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_list_secrets_shape(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
items = secrets_store.list_secrets(config_dir=cfg)
|
||||||
|
required_keys = {"env_var", "is_set", "fields", "label"}
|
||||||
|
for item in items:
|
||||||
|
assert required_keys == set(item.keys()), f"Item missing keys: {item}"
|
||||||
|
|
||||||
|
tomtom = next(i for i in items if i["env_var"] == "TOMTOM_API_KEY")
|
||||||
|
assert tomtom["fields"] == ["environmental.traffic.api_key"]
|
||||||
|
assert isinstance(tomtom["is_set"], bool)
|
||||||
|
assert isinstance(tomtom["label"], str)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# delete on missing file is a no-op (not an error)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_delete_missing_file_noop(tmp_path):
|
||||||
|
cfg = _cfg(tmp_path)
|
||||||
|
# .env file does not exist yet — should not raise
|
||||||
|
secrets_store.delete_secret("SMTP_PASSWORD", config_dir=cfg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# llm_env_var backend mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_llm_env_var_known_backends():
|
||||||
|
assert secrets_store.llm_env_var("openai") == "OPENAI_API_KEY"
|
||||||
|
assert secrets_store.llm_env_var("anthropic") == "ANTHROPIC_API_KEY"
|
||||||
|
assert secrets_store.llm_env_var("google") == "GOOGLE_API_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_env_var_unknown_falls_back():
|
||||||
|
assert secrets_store.llm_env_var("ollama") == "LLM_API_KEY"
|
||||||
|
assert secrets_store.llm_env_var(None) == "LLM_API_KEY"
|
||||||
|
assert secrets_store.llm_env_var("") == "LLM_API_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _env_to_fields: LLM vars include llm.api_key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_env_to_fields_llm():
|
||||||
|
m = secrets_store._env_to_fields()
|
||||||
|
for var in ("GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_API_KEY"):
|
||||||
|
assert "llm.api_key" in m.get(var, []), f"Missing llm.api_key for {var}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SECRET_LABELS completeness
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_secret_labels_keys():
|
||||||
|
expected = {
|
||||||
|
"GOOGLE_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LLM_API_KEY",
|
||||||
|
"TOMTOM_API_KEY", "FIRMS_MAP_KEY", "ROADS511_API_KEY", "WZDX_API_KEY",
|
||||||
|
"SMTP_PASSWORD", "MESHMONITOR_API_TOKEN", "MQTT_PASSWORD",
|
||||||
|
}
|
||||||
|
assert set(secrets_store.SECRET_LABELS.keys()) == expected
|
||||||
Loading…
Add table
Add a link
Reference in a new issue