feat: Hairpin-NAT-Lösung, CA-Zertifikate und konfigurierbarer Kalendername
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 2m1s
Run Tests / test (pull_request) Successful in 5m2s

This commit is contained in:
2026-03-28 15:03:49 +01:00
parent 838e702cf0
commit c000664165
4 changed files with 117 additions and 18 deletions

View File

@@ -16,9 +16,65 @@ import (
)
const (
ConstCalendarName = "Birthdays"
ConstProductID = "-//scriptos//MailcowBirthdayDaemon//EN"
)
// cleanupOldCalendar prüft, ob ein alter Daemon-Kalender existiert, und löscht
// ihn nur, wenn alle enthaltenen Events die Daemon-PRODID tragen. Enthält der
// Kalender fremde Events, wird er nicht gelöscht und eine Warnung geloggt.
func (d *Daemon) cleanupOldCalendar(ctx context.Context, httpClient webdav.HTTPClient, user, oldName string) error {
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
if err != nil {
return err
}
cl, err := caldav.NewClient(httpClient, endpoint)
if err != nil {
return err
}
cc, err := cl.FindCalendars(ctx, "")
if err != nil {
return err
}
found := false
for _, c := range cc {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", oldName)) {
found = true
break
}
}
if !found {
return nil
}
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, oldName)
events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
},
CompFilter: caldav.CompFilter{
Name: "VCALENDAR",
Comps: []caldav.CompFilter{{
Name: "VEVENT",
}},
},
})
if err != nil {
return err
}
for _, ev := range events {
prodID := ev.Data.Props.Get(ical.PropProductID)
if prodID == nil || prodID.Value != ConstProductID {
slog.WarnContext(ctx, "old calendar contains foreign events, skipping cleanup",
"user", user, "calendar", oldName)
return nil
}
}
if err := cl.RemoveAll(ctx, calendarPath); err != nil {
return fmt.Errorf("error removing old calendar: %w", err)
}
slog.InfoContext(ctx, "removed old birthday calendar", "user", user, "calendar", oldName)
return nil
}
func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPClient, user string) error {
endpoint, err := url.JoinPath(d.baseURL, "SOGo/dav", user, "Calendar/")
if err != nil {
@@ -33,11 +89,11 @@ func (d *Daemon) ensureBirthdayCal(ctx context.Context, httpClient webdav.HTTPCl
return err
}
for _, c := range cc {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", ConstCalendarName)) {
if strings.HasSuffix(c.Path, fmt.Sprintf("/%s", d.calendarName)) {
return nil
}
}
if err := cl.Mkdir(ctx, ConstCalendarName); err != nil {
if err := cl.Mkdir(ctx, d.calendarName); err != nil {
return err
}
slog.InfoContext(ctx, "created birthday calendar", "user", user)
@@ -53,7 +109,7 @@ func (d *Daemon) syncBirthdaysToCal(ctx context.Context, httpClient webdav.HTTPC
if err != nil {
return err
}
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, ConstCalendarName)
calendarPath := fmt.Sprintf("/SOGo/dav/%s/Calendar/%s", user, d.calendarName)
events, err := cl.QueryCalendar(ctx, calendarPath, &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
@@ -153,7 +209,7 @@ func icalMatchesBev(ic *ical.Component, bev birthdayEvent) bool {
func (bev birthdayEvent) generateICAL(calendar string) (string, *ical.Calendar) {
id := uuid.New().String()
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropProductID, "-//scriptos//MailcowBirthdayDaemon//EN")
cal.Props.SetText(ical.PropProductID, ConstProductID)
cal.Props.SetText(ical.PropVersion, "2.0")
event := ical.NewComponent(ical.CompEvent)
event.Props.SetText(ical.PropUID, id)

View File

@@ -26,13 +26,15 @@ var (
)
type Daemon struct {
httpClient *http.Client
baseURL string
mailcowClient mailcow.Client
userTokens map[string]string
userTokensLock *sync.RWMutex
stateFilepath string
stateUnsaved bool
httpClient *http.Client
baseURL string
mailcowClient mailcow.Client
userTokens map[string]string
userTokensLock *sync.RWMutex
stateFilepath string
stateUnsaved bool
calendarName string
oldCalendarName string
}
func main() {
@@ -52,12 +54,17 @@ func run() error {
if mailcowAPIKey == "" {
return fmt.Errorf("MAILCOW_APIKEY environment variable is not set")
}
calendarName := os.Getenv("CALENDAR_NAME")
if calendarName == "" {
calendarName = "Birthdays"
}
d := &Daemon{
userTokens: make(map[string]string),
userTokensLock: &sync.RWMutex{},
baseURL: mailcowBase,
stateFilepath: os.Getenv("STATEFILE"),
httpClient: &http.Client{Transport: buildTransport()},
calendarName: calendarName,
}
if len(d.stateFilepath) == 0 {
d.stateFilepath = "state.json"
@@ -98,6 +105,7 @@ func (d *Daemon) daemonRun() error {
})
}
eg.Wait()
d.oldCalendarName = ""
if d.stateUnsaved {
slog.Info("saving tokens to disk", "count", len(d.userTokens))
if err := d.saveState(); err != nil {
@@ -117,6 +125,11 @@ func (d *Daemon) processUser(ctx context.Context, m mailcow.Mailbox) error {
return fmt.Errorf("error getting userpass: %w", err)
}
davclient := webdav.HTTPClientWithBasicAuth(d.httpClient, m.Username, pass)
if d.oldCalendarName != "" {
if err := d.cleanupOldCalendar(ctx, davclient, m.Username, d.oldCalendarName); err != nil {
slog.WarnContext(ctx, "error cleaning up old calendar", "err", err, "user", m.Username)
}
}
bb, err := d.getBirthdays(ctx, davclient, m.Username)
if err != nil {
if strings.HasPrefix(err.Error(), "401 Unauthorized: ") {

View File

@@ -16,6 +16,7 @@ func (d *Daemon) loadState() error {
if err := d.loadFromDisk(&stateVer); err != nil {
return fmt.Errorf("cant detect state version: %w", err)
}
var storedCalendarName string
switch stateVer.Version {
case 0:
slog.Warn("loading old state version", "stateVer", stateVer.Version)
@@ -38,6 +39,30 @@ func (d *Daemon) loadState() error {
}
d.userTokens[k] = string(dec)
}
d.stateUnsaved = true
case 2:
state := struct {
Version int `json:"version"`
UserTokens map[string]string `json:"userTokens"`
CalendarName string `json:"calendarName"`
}{}
if err := d.loadFromDisk(&state); err != nil {
return fmt.Errorf("cant load state v%d: %w", stateVer.Version, err)
}
for k, v := range state.UserTokens {
dec, err := base64.StdEncoding.DecodeString(v)
if err != nil {
return fmt.Errorf("cant decode pass from %s: %w", k, err)
}
d.userTokens[k] = string(dec)
}
storedCalendarName = state.CalendarName
}
if storedCalendarName != "" && storedCalendarName != d.calendarName {
slog.Info("calendar name changed, old calendars will be cleaned up",
"old", storedCalendarName, "new", d.calendarName)
d.oldCalendarName = storedCalendarName
d.stateUnsaved = true
}
return nil
}
@@ -48,11 +73,13 @@ func (d *Daemon) saveState() error {
encTokens[k] = base64.StdEncoding.EncodeToString([]byte(v))
}
state := struct {
Version int `json:"version"`
UserTokens map[string]string `json:"userTokens"`
Version int `json:"version"`
UserTokens map[string]string `json:"userTokens"`
CalendarName string `json:"calendarName"`
}{
Version: 1,
UserTokens: encTokens,
Version: 2,
UserTokens: encTokens,
CalendarName: d.calendarName,
}
return d.saveToDisk(state)
}