Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 70 additions & 16 deletions cmd/import_api_site.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (

"github.com/ravinald/wifimgr/internal/cmdutils"
"github.com/ravinald/wifimgr/internal/config"
"github.com/ravinald/wifimgr/internal/encryption"
"github.com/ravinald/wifimgr/internal/logging"
"github.com/ravinald/wifimgr/internal/vendors"
"github.com/ravinald/wifimgr/internal/vendors/meraki"
Expand All @@ -53,7 +54,7 @@ const (

// importAPISiteCmd represents the "import api site" command
var importAPISiteCmd = &cobra.Command{
Use: "site <site-name> [config|inventory|all] [full|type <scope>] [source <api-label>] [secrets] [compare] [save] [file <filename>]",
Use: "site <site-name> [config|inventory|all] [full|type <scope>] [source <api-label>] [secrets|decrypt] [compare] [save] [file <filename>]",
Short: "Import site configuration from API cache",
Long: `Import site configuration from the API cache.

Expand Down Expand Up @@ -106,15 +107,17 @@ Arguments:
- ap Access point configurations only
- switch Switch configurations only
- gateway Gateway/firewall configurations only
secrets Optional. Include sensitive data (PSK, RADIUS secrets) - redacted by default
secrets Optional. Include PSK/RADIUS secrets, masked as "*secret*"
decrypt Optional. Include secrets decrypted to plaintext (implies secrets);
needs the encryption password (WIFIMGR_PASSWORD or prompt)
compare Optional. Compare API state with existing import file (using jsondiff)
save Optional. Write to import file (default: print to STDOUT)
file Optional. Keyword followed by output filename (relative to config_dir or absolute)

What it Does:
1. Retrieves site configuration from the specified API cache
2. Optionally filters to specific scope (wlans, profiles, ap, switch, gateway)
3. Redacts secrets by default (use 'secrets' to include)
3. Omits secrets by default; 'secrets' masks them, 'decrypt' reveals plaintext
4. Prints to STDOUT or saves to a single import file if 'save' specified
5. With 'compare': shows diff between API and existing local import file

Expand Down Expand Up @@ -279,7 +282,11 @@ func runImportAPISite(cmd *cobra.Command, args []string) error {

// Build the combined ImportFile envelope (site config + site-local WLAN
// templates live side-by-side so the file self-describes).
exportData, err := buildSiteExportData(cacheAccessor, site, parsed.scope, parsed.IncludeSecrets)
reveal, err := resolveSecretReveal(parsed.IncludeSecrets, parsed.Decrypt)
if err != nil {
return err
}
exportData, err := buildSiteExportData(cacheAccessor, site, parsed.scope, reveal)
if err != nil {
return fmt.Errorf("failed to build export data: %w", err)
}
Expand Down Expand Up @@ -582,18 +589,64 @@ type templatesEnvelope struct {
Device map[string]map[string]any `json:"device,omitempty"`
}

// maskedSecret is the placeholder emitted for a secret the caller asked to
// include but not decrypt.
const maskedSecret = "*secret*"

// secretReveal controls how WLAN secrets are emitted on import. include turns
// the secret fields on at all; decrypt swaps the masked placeholder for the
// plaintext, which needs the encryption password.
type secretReveal struct {
include bool
decrypt bool
password string
}

// resolveSecretReveal builds the reveal policy from the parsed keywords,
// prompting for the encryption password when decrypt was requested.
func resolveSecretReveal(include, decrypt bool) (secretReveal, error) {
r := secretReveal{include: include || decrypt, decrypt: decrypt}
if decrypt {
pw, err := encryption.GetPasswordOrPrompt("Enter encryption password to decrypt secrets: ")
if err != nil {
return secretReveal{}, fmt.Errorf("decrypt requires the encryption password: %w", err)
}
r.password = pw
}
return r, nil
}

// revealSecret renders a stored (encrypted) secret for output: the masked
// placeholder unless decrypt was requested, in which case it's decrypted with
// the supplied password. A decrypt failure falls back to the mask so a wrong
// password neither leaks ciphertext nor aborts the export.
func revealSecret(stored string, reveal secretReveal) string {
if !reveal.decrypt {
return maskedSecret
}
if !encryption.IsEncrypted(stored) {
return stored
}
plain, err := encryption.Decrypt(stored, reveal.password)
if err != nil {
logging.Warnf("Failed to decrypt secret, masking instead: %v", err)
return maskedSecret
}
return plain
}

// buildSiteExportData builds an ImportFile-shaped envelope containing both
// the site config and the companion WLAN templates used by that site. Returns
// nil when the scope produces nothing to emit.
func buildSiteExportData(cacheAccessor *vendors.CacheAccessor, site *vendors.SiteInfo, scope ImportScope, includeSecrets bool) (*importEnvelope, error) {
func buildSiteExportData(cacheAccessor *vendors.CacheAccessor, site *vendors.SiteInfo, scope ImportScope, reveal secretReveal) (*importEnvelope, error) {
siteSlug := slug(site.Name)

// WLANs always processed first so label references are ready to attach.
var wlanLabels []string
var wlanTemplates map[string]map[string]any
var wlanAssign map[string][]string
if scope == ScopeFull || scope == ScopeWLANs {
wlanLabels, wlanTemplates, wlanAssign = buildWLANsExport(cacheAccessor, site.ID, siteSlug, includeSecrets)
wlanLabels, wlanTemplates, wlanAssign = buildWLANsExport(cacheAccessor, site.ID, siteSlug, reveal)
}

env := &importEnvelope{
Expand Down Expand Up @@ -969,9 +1022,9 @@ func buildDevicesExport(cacheAccessor *vendors.CacheAccessor, siteID string, sou
// to specific APs (apply_to=aps); the caller wires those into device-level wlan
// lists so apply resolves them back to the same ap_ids — a functional no-op.
// Meraki carries its availability inside the WLAN's vendor block instead.
func buildWLANsExport(cacheAccessor *vendors.CacheAccessor, siteID, siteSlug string, includeSecrets bool) ([]string, map[string]map[string]any, map[string][]string) {
func buildWLANsExport(cacheAccessor *vendors.CacheAccessor, siteID, siteSlug string, reveal secretReveal) ([]string, map[string]map[string]any, map[string][]string) {
wlans := cacheAccessor.GetWLANsBySite(siteID)
labels, profiles, vendorBlocks := synthesizeWLANLabels(wlans, siteSlug, includeSecrets)
labels, profiles, vendorBlocks := synthesizeWLANLabels(wlans, siteSlug, reveal)
if len(profiles) == 0 {
return labels, nil, nil
}
Expand Down Expand Up @@ -1056,7 +1109,7 @@ func profileToMap(p *config.WLANProfile) (map[string]any, error) {
// matching WLANProfile map, and any per-label vendor blocks (e.g. the Meraki
// SSID slot) that pin the WLAN to its native identity. Separated so unit tests
// can drive it directly.
func synthesizeWLANLabels(vendorWLANs []*vendors.WLAN, siteSlug string, includeSecrets bool) ([]string, map[string]*config.WLANProfile, map[string]map[string]any) {
func synthesizeWLANLabels(vendorWLANs []*vendors.WLAN, siteSlug string, reveal secretReveal) ([]string, map[string]*config.WLANProfile, map[string]map[string]any) {
if len(vendorWLANs) == 0 {
return nil, nil, nil
}
Expand All @@ -1074,7 +1127,7 @@ func synthesizeWLANLabels(vendorWLANs []*vendors.WLAN, siteSlug string, includeS
}
used[base]++

profiles[label] = convertVendorWLANToProfile(w, includeSecrets)
profiles[label] = convertVendorWLANToProfile(w, reveal)
if block := vendorBlockForWLAN(w); block != nil {
vendorBlocks[label] = block
}
Expand Down Expand Up @@ -1179,8 +1232,9 @@ func merakiSlot(w *vendors.WLAN) (int, bool) {
// (network IDs, ipAssignmentMode, etc.) is deliberately dropped here — this is
// the portable half. The one binding worth keeping, the Meraki SSID slot, rides
// alongside in a vendor block (see vendorBlockForWLAN), not in the profile. PSK
// and RADIUS secrets honor includeSecrets and are otherwise omitted.
func convertVendorWLANToProfile(w *vendors.WLAN, includeSecrets bool) *config.WLANProfile {
// and RADIUS secrets honor the reveal policy: omitted unless requested, masked
// as a placeholder by default, and decrypted only when decrypt was passed.
func convertVendorWLANToProfile(w *vendors.WLAN, reveal secretReveal) *config.WLANProfile {
profile := &config.WLANProfile{
SSID: w.SSID,
Enabled: w.Enabled,
Expand All @@ -1192,8 +1246,8 @@ func convertVendorWLANToProfile(w *vendors.WLAN, includeSecrets bool) *config.WL
},
}

if includeSecrets && w.PSK != "" {
profile.Auth.PSK = w.PSK
if reveal.include && w.PSK != "" {
profile.Auth.PSK = revealSecret(w.PSK, reveal)
}

if pairwise := derivePairwiseFromConfig(w.Config, w.EncryptionMode); len(pairwise) > 0 {
Expand All @@ -1204,8 +1258,8 @@ func convertVendorWLANToProfile(w *vendors.WLAN, includeSecrets bool) *config.WL
servers := make([]config.RADIUSServer, 0, len(w.RadiusServers))
for _, rs := range w.RadiusServers {
cs := config.RADIUSServer{Host: rs.Host, Port: rs.Port}
if includeSecrets {
cs.Secret = rs.Secret
if reveal.include && rs.Secret != "" {
cs.Secret = revealSecret(rs.Secret, reveal)
}
servers = append(servers, cs)
}
Expand Down
58 changes: 46 additions & 12 deletions cmd/import_api_site_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"github.com/ravinald/wifimgr/internal/config"
"github.com/ravinald/wifimgr/internal/encryption"
"github.com/ravinald/wifimgr/internal/vendors"
)

Expand Down Expand Up @@ -194,7 +195,7 @@ func TestConvertVendorWLANToProfile_Meraki(t *testing.T) {
"splashPage": "Click-through",
},
}
got := convertVendorWLANToProfile(w, true)
got := convertVendorWLANToProfile(w, secretReveal{include: true, decrypt: true})

if got.SSID != "Scale Guest" || !got.Enabled || got.VLANID != 10 || got.Band != "dual" {
t.Errorf("basic fields wrong: %+v", got)
Expand All @@ -213,12 +214,45 @@ func TestConvertVendorWLANToProfile_Meraki(t *testing.T) {
}

// With includeSecrets=false, PSK must be stripped.
gotNoSec := convertVendorWLANToProfile(w, false)
gotNoSec := convertVendorWLANToProfile(w, secretReveal{})
if gotNoSec.Auth.PSK != "" {
t.Errorf("PSK leaked when includeSecrets=false: %q", gotNoSec.Auth.PSK)
}
}

// TestConvertVendorWLANToProfile_SecretReveal checks the three output states for
// a secret stored encrypted in the cache: omitted, masked, and decrypted.
func TestConvertVendorWLANToProfile_SecretReveal(t *testing.T) {
const password = "correct horse battery staple"
encPSK, err := encryption.Encrypt("s3cr3t-pass", password)
if err != nil {
t.Fatalf("seed encrypt: %v", err)
}
w := &vendors.WLAN{SSID: "Scale", Enabled: true, AuthType: "psk", PSK: encPSK}

// Omitted: no secrets keyword.
if got := convertVendorWLANToProfile(w, secretReveal{}); got.Auth.PSK != "" {
t.Errorf("PSK should be omitted, got %q", got.Auth.PSK)
}

// Masked: secrets without decrypt never exposes ciphertext or plaintext.
if got := convertVendorWLANToProfile(w, secretReveal{include: true}); got.Auth.PSK != maskedSecret {
t.Errorf("PSK = %q, want %q", got.Auth.PSK, maskedSecret)
}

// Decrypted: correct password yields the plaintext.
got := convertVendorWLANToProfile(w, secretReveal{include: true, decrypt: true, password: password})
if got.Auth.PSK != "s3cr3t-pass" {
t.Errorf("decrypted PSK = %q, want %q", got.Auth.PSK, "s3cr3t-pass")
}

// Wrong password falls back to the mask rather than leaking ciphertext.
bad := convertVendorWLANToProfile(w, secretReveal{include: true, decrypt: true, password: "wrong"})
if bad.Auth.PSK != maskedSecret {
t.Errorf("bad-password PSK = %q, want %q", bad.Auth.PSK, maskedSecret)
}
}

// TestConvertVendorWLANToProfile_MerakiRealWorld mirrors the exact field shape
// a Meraki site produces today (verbose bandSelection string, legacy "wpa"
// encryption mode, iPSK auth type) and checks the output is canonical.
Expand All @@ -235,7 +269,7 @@ func TestConvertVendorWLANToProfile_MerakiRealWorld(t *testing.T) {
"bandSelection": "Dual band operation with Band Steering",
},
}
got := convertVendorWLANToProfile(w, false)
got := convertVendorWLANToProfile(w, secretReveal{})

if got.Band != "dual" {
t.Errorf("Band = %q, want %q", got.Band, "dual")
Expand Down Expand Up @@ -268,7 +302,7 @@ func TestConvertVendorWLANToProfile_MistEnterprise(t *testing.T) {
"portal": map[string]any{"enabled": true, "auth": "sponsor"},
},
}
got := convertVendorWLANToProfile(w, true)
got := convertVendorWLANToProfile(w, secretReveal{include: true, decrypt: true})

if got.Auth.Type != "eap" {
t.Errorf("expected Auth.Type=eap, got %q", got.Auth.Type)
Expand All @@ -287,7 +321,7 @@ func TestConvertVendorWLANToProfile_MistEnterprise(t *testing.T) {
}

// includeSecrets=false strips RADIUS secret but keeps host/port.
gotNoSec := convertVendorWLANToProfile(w, false)
gotNoSec := convertVendorWLANToProfile(w, secretReveal{})
if gotNoSec.Auth.RADIUSServers[0].Secret != "" {
t.Errorf("RADIUS secret leaked: %q", gotNoSec.Auth.RADIUSServers[0].Secret)
}
Expand All @@ -301,7 +335,7 @@ func TestSynthesizeWLANLabels(t *testing.T) {
{SSID: "Scale Guest", Enabled: true, AuthType: "open"},
{SSID: "Scale Robotics", Enabled: true, AuthType: "psk"},
}
labels, profiles, _ := synthesizeWLANLabels(wlans, "mx-mex-904", false)
labels, profiles, _ := synthesizeWLANLabels(wlans, "mx-mex-904", secretReveal{})

if len(labels) != 2 || len(profiles) != 2 {
t.Fatalf("expected 2 labels/profiles, got %d/%d", len(labels), len(profiles))
Expand All @@ -324,7 +358,7 @@ func TestSynthesizeWLANLabels_CollisionSuffix(t *testing.T) {
{SSID: "corp-net", Enabled: true, AuthType: "psk"},
{SSID: "CORP__NET", Enabled: true, AuthType: "psk"},
}
labels, profiles, _ := synthesizeWLANLabels(wlans, "site", false)
labels, profiles, _ := synthesizeWLANLabels(wlans, "site", secretReveal{})

want := []string{"site--corp-net", "site--corp-net-2", "site--corp-net-3"}
if !equalStrings(labels, want) {
Expand All @@ -336,7 +370,7 @@ func TestSynthesizeWLANLabels_CollisionSuffix(t *testing.T) {
}

func TestSynthesizeWLANLabels_Empty(t *testing.T) {
labels, profiles, vendorBlocks := synthesizeWLANLabels(nil, "site", false)
labels, profiles, vendorBlocks := synthesizeWLANLabels(nil, "site", secretReveal{})
if labels != nil || profiles != nil || vendorBlocks != nil {
t.Errorf("empty input should return nil/nil/nil, got %v/%v/%v", labels, profiles, vendorBlocks)
}
Expand All @@ -350,7 +384,7 @@ func TestSynthesizeWLANLabels_PinsMerakiSlot(t *testing.T) {
{SSID: "Scale Guest", Enabled: true, AuthType: "open", SourceVendor: "meraki", ID: "L_123:3", Config: map[string]any{"number": float64(3)}},
{SSID: "Corp", Enabled: true, AuthType: "psk", SourceVendor: "mist", ID: "uuid-abc"},
}
_, templates := buildWLANsExportFromWLANs(wlans, "site", false)
_, templates := buildWLANsExportFromWLANs(wlans, "site", secretReveal{})

block, ok := templates["site--scale-guest"]["meraki:"].(map[string]any)
if !ok {
Expand Down Expand Up @@ -472,7 +506,7 @@ func TestExportRoundTripsThroughLoaderTypes(t *testing.T) {
wlans := []*vendors.WLAN{
{SSID: "Scale Guest", Enabled: true, AuthType: "open"},
}
labels, templates := buildWLANsExportFromWLANs(wlans, "mx-mex-904", false)
labels, templates := buildWLANsExportFromWLANs(wlans, "mx-mex-904", secretReveal{})

env := &importEnvelope{
Version: 1,
Expand Down Expand Up @@ -530,8 +564,8 @@ func TestExportRoundTripsThroughLoaderTypes(t *testing.T) {
// buildWLANsExportFromWLANs is a test helper mirroring buildWLANsExport but
// bypasses the cache accessor so the test can drive synthesizeWLANLabels
// directly and then mimic the profile-to-map conversion.
func buildWLANsExportFromWLANs(ws []*vendors.WLAN, siteSlug string, includeSecrets bool) ([]string, map[string]map[string]any) {
labels, profiles, vendorBlocks := synthesizeWLANLabels(ws, siteSlug, includeSecrets)
func buildWLANsExportFromWLANs(ws []*vendors.WLAN, siteSlug string, reveal secretReveal) ([]string, map[string]map[string]any) {
labels, profiles, vendorBlocks := synthesizeWLANLabels(ws, siteSlug, reveal)
if len(profiles) == 0 {
return labels, nil
}
Expand Down
21 changes: 13 additions & 8 deletions cmd/import_api_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const (
// into a standalone ImportFile. Scoped to a specific API because template
// namespaces live at the API/org boundary.
var importAPITemplatesCmd = &cobra.Command{
Use: "templates [target <api-label>] [type wlan|rf|device|gateway|all] [save] [file <filename>] [secrets]",
Use: "templates [target <api-label>] [type wlan|rf|device|gateway|all] [save] [file <filename>] [secrets|decrypt]",
Short: "Import vendor-level templates from API cache",
Long: `Import vendor-level (org-scoped) templates from a specific API.

Expand All @@ -70,8 +70,9 @@ Arguments:
type <kind> Optional. Template kind to export (default: wlan). Currently
only "wlan" is supported; "rf", "device", "gateway", "all"
are reserved for later coverage.
secrets Optional. Include sensitive data (PSK, RADIUS secrets) —
redacted by default.
secrets Optional. Include PSK/RADIUS secrets, masked as "*secret*".
decrypt Optional. Include secrets decrypted to plaintext (implies
secrets); needs WIFIMGR_PASSWORD or an interactive prompt.
save Optional. Write to import file (default: print to STDOUT).
file Optional. Keyword followed by output filename (relative to
config_dir or absolute).
Expand Down Expand Up @@ -188,7 +189,11 @@ func runImportAPITemplates(cmd *cobra.Command, args []string) error {
}

// Build the envelope. For now only WLANs are supported.
env, err := buildTemplatesExportData(cacheAccessor, parsed.apiLabel, parsed.templateType, parsed.IncludeSecrets)
reveal, err := resolveSecretReveal(parsed.IncludeSecrets, parsed.Decrypt)
if err != nil {
return err
}
env, err := buildTemplatesExportData(cacheAccessor, parsed.apiLabel, parsed.templateType, reveal)
if err != nil {
return fmt.Errorf("failed to build templates export: %w", err)
}
Expand Down Expand Up @@ -243,7 +248,7 @@ func resolveTemplateImportPath(outputFile, configDir, apiLabel string, kind Temp

// buildTemplatesExportData materializes an ImportFile envelope containing
// vendor-level templates for the given API.
func buildTemplatesExportData(cacheAccessor *vendors.CacheAccessor, apiLabel string, kind TemplateImportType, includeSecrets bool) (*importEnvelope, error) {
func buildTemplatesExportData(cacheAccessor *vendors.CacheAccessor, apiLabel string, kind TemplateImportType, reveal secretReveal) (*importEnvelope, error) {
env := &importEnvelope{
Version: 1,
Source: &importSourceExport{
Expand All @@ -256,7 +261,7 @@ func buildTemplatesExportData(cacheAccessor *vendors.CacheAccessor, apiLabel str
switch kind {
case TemplateTypeWLAN:
wlans := collectOrgWLANs(cacheAccessor, apiLabel)
templates := synthesizeOrgWLANTemplates(wlans, includeSecrets)
templates := synthesizeOrgWLANTemplates(wlans, reveal)
if len(templates) == 0 {
return env, nil
}
Expand Down Expand Up @@ -290,7 +295,7 @@ func collectOrgWLANs(cacheAccessor *vendors.CacheAccessor, apiLabel string) []*v
// synthesizeOrgWLANTemplates builds the Templates.WLAN map for org-level
// WLANs. Labels are bare slugs of the SSID — no site prefix, because the
// scope IS the whole org. Collisions append a counter suffix.
func synthesizeOrgWLANTemplates(ws []*vendors.WLAN, includeSecrets bool) map[string]map[string]any {
func synthesizeOrgWLANTemplates(ws []*vendors.WLAN, reveal secretReveal) map[string]map[string]any {
if len(ws) == 0 {
return nil
}
Expand All @@ -308,7 +313,7 @@ func synthesizeOrgWLANTemplates(ws []*vendors.WLAN, includeSecrets bool) map[str
}
used[base]++

profile := convertVendorWLANToProfile(w, includeSecrets)
profile := convertVendorWLANToProfile(w, reveal)
m, err := profileToMap(profile)
if err != nil {
logging.Warnf("[import templates] failed to serialize WLAN %q: %v", w.SSID, err)
Expand Down
Loading
Loading