feat: Hairpin-NAT-Lösung, CA-Zertifikate und konfigurierbarer Kalendername
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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: ") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user