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
130 changes: 129 additions & 1 deletion agent/cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ var firefoxNotifyConfig = notify.Config{
Message: "Firefox policies have been updated. Please restart Firefox for all changes to take effect.",
}

// thunderbirdCache maps policy ID → proto policy for all active Thunderbird policies.
var thunderbirdCache = make(map[string]*pb.ThunderbirdPolicy)

// thunderbirdSnapshotStaging accumulates Thunderbird proto policies during a SNAPSHOT.
var thunderbirdSnapshotStaging map[string]*pb.ThunderbirdPolicy

// thunderbirdNotifier handles desktop notifications for Thunderbird policy changes.
var thunderbirdNotifier = notify.New()

// thunderbirdNotifyConfig holds Thunderbird-specific notification settings.
var thunderbirdNotifyConfig = notify.Config{
Enabled: true,
Cooldown: 5 * time.Minute,
Message: "Thunderbird policies have been updated. Please restart Thunderbird for all changes to take effect.",
}

// chromeCache maps policy ID → proto policy for all active Chrome policies.
var chromeCache = make(map[string]*pb.ChromePolicy)

Expand Down Expand Up @@ -411,6 +427,11 @@ func runStreamingLoop(ctx context.Context, client *policyclient.Client, cfg *con
Cooldown: time.Duration(agentCfg.NotifyCooldown) * time.Second,
Message: agentCfg.NotifyMessageChrome,
}
thunderbirdNotifyConfig = notify.Config{
Enabled: agentCfg.NotifyUsers,
Cooldown: time.Duration(agentCfg.NotifyCooldown) * time.Second,
Message: agentCfg.NotifyMessageThunderbird,
}
packageNotifyConfig = notify.Config{
Enabled: agentCfg.NotifyUsers,
Cooldown: time.Duration(agentCfg.NotifyCooldown) * time.Second,
Expand Down Expand Up @@ -511,12 +532,15 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c
if snapshotComplete {
log.Println("Received empty snapshot (no policies assigned)")
firefoxChanged := len(firefoxCache) > 0
thunderbirdChanged := len(thunderbirdCache) > 0
hadKconfigPolicies := len(kconfigCache) > 0
chromeChanged := len(chromeCache) > 0
kconfigCache = make(map[string]*pb.KConfigPolicy)
kconfigSnapshotStaging = nil
firefoxCache = make(map[string]*pb.FirefoxPolicy)
firefoxSnapshotStaging = nil
thunderbirdCache = make(map[string]*pb.ThunderbirdPolicy)
thunderbirdSnapshotStaging = nil
chromeCache = make(map[string]*pb.ChromePolicy)
chromeSnapshotStaging = nil
dconfCache = make(map[string]dconfCacheEntry)
Expand All @@ -527,6 +551,7 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c
packageSnapshotStaging = nil
syncAllKConfig(ctx, client, cfg)
syncAllFirefox(ctx, client, cfg)
syncAllThunderbird(ctx, client, cfg)
syncAllChrome(ctx, client, cfg)
syncAllDConf(ctx, client, cfg)
syncAllPolkit(ctx, client, cfg)
Expand All @@ -538,6 +563,9 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c
if firefoxChanged {
firefoxNotifier.ScheduleNotification(firefoxNotifyConfig, map[string]bool{"policies.json": true})
}
if thunderbirdChanged {
thunderbirdNotifier.ScheduleNotification(thunderbirdNotifyConfig, map[string]bool{"policies.json": true})
}
if chromeChanged {
chromeNotifier.ScheduleNotification(chromeNotifyConfig, map[string]bool{"bor_managed.json": true})
}
Expand All @@ -556,6 +584,11 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c
firefoxSnapshotStaging = make(map[string]*pb.FirefoxPolicy)
}
firefoxSnapshotStaging[pi.ID] = pi.FirefoxPolicy
case "Thunderbird":
if thunderbirdSnapshotStaging == nil {
thunderbirdSnapshotStaging = make(map[string]*pb.ThunderbirdPolicy)
}
thunderbirdSnapshotStaging[pi.ID] = pi.ThunderbirdPolicy
case "Chrome":
if chromeSnapshotStaging == nil {
chromeSnapshotStaging = make(map[string]*pb.ChromePolicy)
Expand Down Expand Up @@ -588,8 +621,9 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c
}

if snapshotComplete {
// Compare Firefox and Chrome content before swapping to detect changes.
// Compare Firefox, Thunderbird and Chrome content before swapping to detect changes.
firefoxChanged := !firefoxCachesEqual(firefoxCache, firefoxSnapshotStaging)
thunderbirdChanged := !thunderbirdCachesEqual(thunderbirdCache, thunderbirdSnapshotStaging)
chromeChanged := !chromeCachesEqual(chromeCache, chromeSnapshotStaging)

// Swap KConfig staging into cache.
Expand All @@ -608,6 +642,14 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c
}
firefoxSnapshotStaging = nil

// Swap Thunderbird staging into cache.
if thunderbirdSnapshotStaging != nil {
thunderbirdCache = thunderbirdSnapshotStaging
} else {
thunderbirdCache = make(map[string]*pb.ThunderbirdPolicy)
}
thunderbirdSnapshotStaging = nil

// Swap Chrome staging into cache.
if chromeSnapshotStaging != nil {
chromeCache = chromeSnapshotStaging
Expand Down Expand Up @@ -642,6 +684,7 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c

kconfigChanged := syncAllKConfig(ctx, client, cfg)
syncAllFirefox(ctx, client, cfg)
syncAllThunderbird(ctx, client, cfg)
syncAllChrome(ctx, client, cfg)
syncAllDConf(ctx, client, cfg)
syncAllPolkit(ctx, client, cfg)
Expand All @@ -655,6 +698,9 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c
if firefoxChanged {
firefoxNotifier.ScheduleNotification(firefoxNotifyConfig, map[string]bool{"policies.json": true})
}
if thunderbirdChanged {
thunderbirdNotifier.ScheduleNotification(thunderbirdNotifyConfig, map[string]bool{"policies.json": true})
}
if chromeChanged {
chromeNotifier.ScheduleNotification(chromeNotifyConfig, map[string]bool{"bor_managed.json": true})
}
Expand All @@ -675,6 +721,11 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c
if syncAllFirefox(ctx, client, cfg) {
firefoxNotifier.ScheduleNotification(firefoxNotifyConfig, map[string]bool{"policies.json": true})
}
case "Thunderbird":
thunderbirdCache[pi.ID] = pi.ThunderbirdPolicy
if syncAllThunderbird(ctx, client, cfg) {
thunderbirdNotifier.ScheduleNotification(thunderbirdNotifyConfig, map[string]bool{"policies.json": true})
}
case "Chrome":
chromeCache[pi.ID] = pi.ChromePolicy
if syncAllChrome(ctx, client, cfg) {
Expand Down Expand Up @@ -717,6 +768,11 @@ func handlePolicyUpdate(ctx context.Context, client *policyclient.Client, cfg *c
if syncAllFirefox(ctx, client, cfg) {
firefoxNotifier.ScheduleNotification(firefoxNotifyConfig, map[string]bool{"policies.json": true})
}
} else if _, ok := thunderbirdCache[pi.ID]; ok {
delete(thunderbirdCache, pi.ID)
if syncAllThunderbird(ctx, client, cfg) {
thunderbirdNotifier.ScheduleNotification(thunderbirdNotifyConfig, map[string]bool{"policies.json": true})
}
} else if _, ok := chromeCache[pi.ID]; ok {
delete(chromeCache, pi.ID)
if syncAllChrome(ctx, client, cfg) {
Expand Down Expand Up @@ -910,6 +966,66 @@ func syncAllFirefox(ctx context.Context, client *policyclient.Client, cfg *confi
return true
}

// thunderbirdCachesEqual returns true when two Thunderbird policy caches contain
// identical policy IDs and proto content. Used to detect whether a SNAPSHOT
// resync actually changed the Thunderbird policy set.
func thunderbirdCachesEqual(a, b map[string]*pb.ThunderbirdPolicy) bool {
if len(a) != len(b) {
return false
}
for id, pa := range a {
pb2, ok := b[id]
if !ok {
return false
}
if !proto.Equal(pa, pb2) {
return false
}
}
return true
}

// syncAllThunderbird re-merges all cached Thunderbird proto policies and syncs
// the resulting policies.json to disk. When the cache is empty,
// SyncThunderbirdPoliciesFromProto restores the original file from backup.
//
// Returns true when the sync succeeded (for notification scheduling).
func syncAllThunderbird(ctx context.Context, client *policyclient.Client, cfg *config.Config) bool {
var policies []*pb.ThunderbirdPolicy
var ids []string
for id, pol := range thunderbirdCache {
policies = append(policies, pol)
ids = append(ids, id)
}

suppressManagedWrites(cfg, cfg.Thunderbird.PoliciesPath, cfg.Thunderbird.FlatpakPoliciesPath)
defer updateWatcher(cfg)

if err := policy.SyncThunderbirdPoliciesFromProto(cfg.Thunderbird.PoliciesPath, policies); err != nil {
log.Printf("Error syncing Thunderbird policies: %v", err)
for _, id := range ids {
_ = client.ReportCompliance(ctx, id, false, "failed to sync Thunderbird policies: "+err.Error())
}
return false
}

// Flatpak Thunderbird: write to the system-wide extension directory.
// This is best-effort — Flatpak Thunderbird may not be installed.
if cfg.Thunderbird.FlatpakPoliciesPath != "" {
if err := policy.SyncThunderbirdFlatpakPoliciesFromProto(cfg.Thunderbird.FlatpakPoliciesPath, policies); err != nil {
log.Printf("Warning: failed to sync Flatpak Thunderbird policies: %v", err)
} else {
log.Printf("Flatpak Thunderbird policies synced to %s", cfg.Thunderbird.FlatpakPoliciesPath)
}
}

log.Printf("Thunderbird policies synced to %s (%d policies)", cfg.Thunderbird.PoliciesPath, len(ids))
for _, id := range ids {
_ = client.ReportCompliance(ctx, id, true, "Deployed")
}
return true
}

// sendHeartbeat collects current system metadata and sends it to the server.
func sendHeartbeat(ctx context.Context, client *policyclient.Client) {
si := sysinfo.Collect()
Expand Down Expand Up @@ -1430,6 +1546,16 @@ func getManagedPaths(cfg *config.Config) []string {
}
}

// Thunderbird.
if len(thunderbirdCache) > 0 {
if cfg.Thunderbird.PoliciesPath != "" {
paths = append(paths, cfg.Thunderbird.PoliciesPath)
}
if cfg.Thunderbird.FlatpakPoliciesPath != "" {
paths = append(paths, cfg.Thunderbird.FlatpakPoliciesPath)
}
}

// Chrome.
if len(chromeCache) > 0 {
for _, dir := range []string{
Expand Down Expand Up @@ -1530,6 +1656,8 @@ func onTamperedFile(ctx context.Context, client *policyclient.Client, cfg *confi
syncAllKConfig(ctx, client, cfg)
case path == cfg.Firefox.PoliciesPath || path == cfg.Firefox.FlatpakPoliciesPath:
syncAllFirefox(ctx, client, cfg)
case path == cfg.Thunderbird.PoliciesPath || path == cfg.Thunderbird.FlatpakPoliciesPath:
syncAllThunderbird(ctx, client, cfg)
case strings.HasPrefix(path, "/etc/dconf/"):
syncAllDConf(ctx, client, cfg)
case strings.HasPrefix(path, policy.PolkitRulesDir+string(filepath.Separator)):
Expand Down
25 changes: 18 additions & 7 deletions agent/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ import (

// Config holds the agent configuration.
type Config struct {
Server ServerConfig `yaml:"server"`
Agent AgentConfig `yaml:"agent"`
Firefox FirefoxConfig `yaml:"firefox"`
Chrome ChromeConfig `yaml:"chrome"`
KConfig KConfigConfig `yaml:"kconfig"`
Enrollment EnrollmentConfig `yaml:"enrollment"`
Kerberos KerberosConfig `yaml:"kerberos"`
Server ServerConfig `yaml:"server"`
Agent AgentConfig `yaml:"agent"`
Firefox FirefoxConfig `yaml:"firefox"`
Thunderbird ThunderbirdConfig `yaml:"thunderbird"`
Chrome ChromeConfig `yaml:"chrome"`
KConfig KConfigConfig `yaml:"kconfig"`
Enrollment EnrollmentConfig `yaml:"enrollment"`
Kerberos KerberosConfig `yaml:"kerberos"`
}

// ServerConfig holds server connection settings.
Expand Down Expand Up @@ -57,6 +58,12 @@ type FirefoxConfig struct {
FlatpakPoliciesPath string `yaml:"flatpak_policies_path"`
}

// ThunderbirdConfig holds Thunderbird policy file settings.
type ThunderbirdConfig struct {
PoliciesPath string `yaml:"policies_path"`
FlatpakPoliciesPath string `yaml:"flatpak_policies_path"`
}

// ChromeConfig holds Chrome/Chromium policy directory settings.
// The agent writes bor_managed.json into each configured directory.
type ChromeConfig struct {
Expand Down Expand Up @@ -130,6 +137,10 @@ func DefaultConfig() *Config {
PoliciesPath: "/etc/firefox/policies/policies.json",
FlatpakPoliciesPath: "/var/lib/flatpak/extension/org.mozilla.firefox.systemconfig/" + flatpakArch() + "/stable/policies/policies.json",
},
Thunderbird: ThunderbirdConfig{
PoliciesPath: "/etc/thunderbird/policies/policies.json",
FlatpakPoliciesPath: "/var/lib/flatpak/extension/net.thunderbird.Thunderbird.systemconfig/" + flatpakArch() + "/stable/policies/policies.json",
},
Chrome: ChromeConfig{
ChromePoliciesPath: "/etc/opt/chrome/policies/managed",
ChromiumPoliciesPath: "/etc/chromium/policies/managed",
Expand Down
96 changes: 96 additions & 0 deletions agent/internal/policy/thunderbird.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: LGPL-3.0-or-later
// Copyright (C) 2026 Vute Tech LTD
// Copyright (C) 2026 Bor contributors

package policy

import (
"encoding/json"
"fmt"
"os"

pb "github.com/VuteTech/Bor/server/pkg/grpc/policy"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)

// ThunderbirdManagedComment is written into policies.json when the file is
// under Bor management. Thunderbird ignores unknown root-level keys, so this
// is safe to include and serves as a human-visible marker.
const ThunderbirdManagedComment = "This file is managed by Bor. Do not edit manually. Changes will be overwritten by policy enforcement."

// MergeThunderbirdProtos merges multiple ThunderbirdPolicy proto messages into one.
// Uses proto.Merge semantics: singular optional fields from later policies
// overwrite earlier ones; repeated fields are appended.
func MergeThunderbirdProtos(policies []*pb.ThunderbirdPolicy) *pb.ThunderbirdPolicy {
merged := &pb.ThunderbirdPolicy{}
for _, p := range policies {
if p != nil {
proto.Merge(merged, p)
}
}
return merged
}

// SyncThunderbirdPoliciesFromProto merges the given proto policies and writes
// policies.json to targetPath. When policies is empty, restores the original.
func SyncThunderbirdPoliciesFromProto(targetPath string, policies []*pb.ThunderbirdPolicy) error {
if len(policies) == 0 {
return RestoreOriginal(targetPath)
}
if err := BackupOriginal(targetPath); err != nil {
return fmt.Errorf("failed to backup Thunderbird policies: %w", err)
}
data, err := marshalThunderbirdPolicies(policies)
if err != nil {
return err
}
return WriteFileAtomically(targetPath, data)
}

// SyncThunderbirdFlatpakPoliciesFromProto writes merged Thunderbird policies
// to the Flatpak extension directory. No backup/restore — Bor owns this file.
func SyncThunderbirdFlatpakPoliciesFromProto(targetPath string, policies []*pb.ThunderbirdPolicy) error {
if len(policies) == 0 {
if err := os.Remove(targetPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove Flatpak Thunderbird policies: %w", err)
}
return nil
}
data, err := marshalThunderbirdPolicies(policies)
if err != nil {
return err
}
return WriteFileAtomically(targetPath, data)
}

// marshalThunderbirdPolicies merges the given policies and marshals them into
// the policies.json format Thunderbird expects: {"_comment": "...", "policies": {...}}.
func marshalThunderbirdPolicies(policies []*pb.ThunderbirdPolicy) ([]byte, error) {
merged := MergeThunderbirdProtos(policies)

opts := protojson.MarshalOptions{EmitUnpopulated: false}
jsonBytes, err := opts.Marshal(merged)
if err != nil {
return nil, fmt.Errorf("failed to marshal Thunderbird policy proto: %w", err)
}

var policiesMap map[string]interface{}
if unmarshalErr := json.Unmarshal(jsonBytes, &policiesMap); unmarshalErr != nil {
return nil, fmt.Errorf("failed to parse marshalled Thunderbird policy: %w", unmarshalErr)
}

managed := struct {
Comment string `json:"_comment"`
Policies map[string]interface{} `json:"policies"`
}{
Comment: ThunderbirdManagedComment,
Policies: policiesMap,
}

data, err := json.MarshalIndent(managed, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal Thunderbird policies: %w", err)
}
return append(data, '\n'), nil
}
Loading
Loading