diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac127d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/ical-filter-proxy +/config.yaml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..c98de28 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,19 @@ +--- +include: + - project: gitlab/includes # private gitlab instance + file: + - docker.yml + - go.yml + - helm.yml +variables: + CI_DOCKER_BUILD: "true" + CI_DOCKER_DHUB_REPO: "yungwood/ical-filter-proxy" + +go:build: + artifacts: + paths: + - ical-filter-proxy + +docker:build: + needs: + - go:build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2d600a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM alpine:3.20.2 + +# build args and defaults +ARG BUILD_DATE="not-set" +ARG REVISION="unknown" +ARG VERSION="dev-build" + +# set some labels +LABEL org.opencontainers.image.created="$BUILD_DATE" \ + org.opencontainers.image.base.name="alpine:3.20.2" \ + org.opencontainers.image.documentation="https://github.com/yungwood/ical-filter-proxy/tree/main/README.md" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.source="https://github.com/yungwood/ical-filter-proxy" \ + org.opencontainers.image.revision="$REVISION" \ + org.opencontainers.image.title="iCal Filter Proxy" \ + org.opencontainers.image.description="iCal proxy with support for user-defined filtering rules" \ + org.opencontainers.image.version="$VERSION" + +# install dependencies +RUN apk --no-cache add gcompat=1.1.0-r4 + +# create a group and user +RUN addgroup -S icalfilterproxy && adduser -S -G icalfilterproxy icalfilterproxy + +# switch to app user +USER icalfilterproxy + +# set working dir +WORKDIR /app + +# copy binary +COPY ical-filter-proxy /usr/bin/ical-filter-proxy + +# expose port, define entrypoint +EXPOSE 8080/tcp +ENTRYPOINT ["/usr/bin/ical-filter-proxy"] +CMD ["-config", "/app/config.yaml"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..57e0ebf --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ + +
+
+ + Logo + + +

iCal Filter Proxy

+ +

+ iCal proxy with support for user-defined filtering rules +

+
+ + +## What's this thing? + +Do you have iCal feeds with a bunch of stuff you *don't* need? Do you want to modify events generated by your rostering system? + +iCal Filter Proxy is a simple service for proxying multiple iCal feeds while applying a list of filters to remove or modify events to suit your use case. + +### Features + +* Proxy multiple calendars +* Define a list of filters per calendar +* Match events using basic text and regex conditions +* Remove or modify events as they are proxied + + +### Built With + +* Go +* [golang-ical](https://github.com/arran4/golang-ical) +* [yaml.v3](https://github.com/go-yaml/yaml/tree/v3.0.1) +* [DALL-E 2](https://openai.com/index/dall-e-2/) (app icon) + + +## Setup + +### Docker + +Docker images are published to [Docker Hub](https://hub.docker.com/repository/docker/yungwood/ical-filter-proxy). You'll need a config file (see below) mounted into the container at `/app/config.yaml`. + +For example: + +```bash +docker run -d \ + --name=ical-filter-proxy \ + -v config.yaml:/app/config.yaml \ + -p 8080:8080/tcp \ + --restart unless-stopped \ + yungwood/ical-filter-proxy:latest +``` + +You can also adapt the included [`docker-compose.yaml`](./docker-compose.yaml) example. + +### Kubernetes + +You can deploy iCal Filter Proxy using the included helm chart from [`charts/ical-filter-proxy`](charts/ical-filter-proxy). + +### Build from source + +You can also build the app and container from source. + +```bash +# clone this repo +git clone git@github.com:yungwood/ical-filter-proxy.git +cd ical-filter-proxy + +# build the ical-filter-proxy binary +go build . + +# build container image +docker build \ + --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + --build-arg REVISION=$(git rev-parse HEAD) \ + --build-arg VERSION=$(git rev-parse --short HEAD) \ + -t ical-filter-proxy:latest . +``` + + +## Configuration + +Calendars and filters are defined in a yaml config file. By default this is `config.yaml` (use the `-config` switch to change this). The configuration must define at least one calendar for ical-filter-proxy to start. + +Example configuration (with comments): + +```yaml +calendars: + + # basic example + - name: example # used as slug in URL - e.g. ical-filter-proxy:8080/calendars/example/feed?token=changeme + token: "changeme" # token used to pull iCal feed - authentication is disabled when blank + feed_url: "https://my-upstream-calendar.url/feed.ics" # URL for the upstream iCal feed + filters: # optional - if no filters defined the upstream calendar is proxied as parsed + - description: "Remove an event based on a regex" + remove: true # events matching this filter will be removed + match: # optional - all events will match if no rules defined + summary: # match on event summary (title) + contains: "deleteme" # must contain 'deleteme' + - description: "Remove descriptions from all events" + transform: # optional + description: # modify event description + remove: true # replace with a blank string + + # example: removing noise from an Office 365 calendar + - name: outlook + token: "changeme" + feed_url: "https://outlook.office365.com/owa/calendar/.../reachcalendar.ics" + filters: + - description: "Remove canceled events" # canceled events remain with a 'Canceled:' prefix until removed + remove: true + match: + summary: + prefix: "Canceled: " + - description: "Remove optional events" + remove: true + match: + summary: + prefix: "[Optional]" + - description: "Remove public holidays" + remove: true + match: + summary: + regex_match: ".*[Pp]ublic [Hh]oliday.*" + + # example: cleaning up an OpsGenie feed + - name: opsgenie + token: "changeme" + feed_url: "https://company.app.opsgenie.com/webapi/webcal/getRecentSchedule?webcalToken=..." + filters: + - description: "Keep oncall schedule events and fix names" + match: + summary: + contains: "schedule: oncall" + stop: true # stops processing any more filters + transform: + summary: + replace: "On-Call" # replace the event summary (title) + - description: "Remove all other events" + remove: true + +unsafe: false # optional - must be enabled if any calendars do not have a token +``` + + +### Filters + +Calendar events are filtered using a similar concept to email filtering. A list of filters is defined for each calendar in the config. + +Each event parsed from `feed_url` is evaluated against the filters in sequence. + +* All `match` rules for a filter must be true to match an event +* A filter with no `match` rules will *always* match +* When a match is found: + * if `remove` is `true` the event is discarded + * `transform` rules are applied to the event + * if `stop` is `true` no more filters are processed +* If no match is found the event is retained by default + +#### Match conditions + +Each filter can spcify match conditions against the following event properties: + +* `summary` (string value) +* `location` (string value) +* `description` (string value) + +These match conditions are available for a string value: + +* `contains` - property must contain this value +* `prefix` - property must start with this value +* `suffix` - property must end with this value +* `regex_match` - property must match the given regular expression (an invalid regex will result in no matches) + +#### Transformations + +Transformations can be applied to the following event properties: + +* `summary` - string value +* `location` - string value +* `description` - string value + +The following transformations are available for strings: + +* `replace` - the property is replace with this value +* `remove` - if `true` the property is set to a blank string + + +## Roadmap to 1.0 + +There are a few more features I would like to add before I call the project "stable" and release version 1.0. + +- [ ] Time based event conditions +- [ ] Caching +- [ ] Support for `ical_url_file` and `token_file` in config (vault secrets) +- [ ] Prometheus metrics +- [ ] Testing + + +## Contributing + +If you have a suggestion that would make this better, please feel free to open an issue or send a pull request. + + +## License + +This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. + + +## Acknowledgments + +This project was inspired by [darkphnx/ical-filter-proxy](https://github.com/darkphnx/ical-filter-proxy). I needed more flexibility with filtering rules and the ability to modify event descriptions... plus I wanted an excuse to finally write something in Go. diff --git a/calendar.go b/calendar.go new file mode 100644 index 0000000..3d4dfa1 --- /dev/null +++ b/calendar.go @@ -0,0 +1,259 @@ +package main + +import ( + "bytes" + "io" + "log/slog" + "net/http" + "regexp" + "strings" + + ics "github.com/arran4/golang-ical" +) + +// All structs defined in this file are used to unmarshall yaml configuration and +// provide helper functions that are used to fetch and filter events + +// CalendarConfig definition +type CalendarConfig struct { + Name string `yaml:"name"` + Token string `yaml:"token"` + FeedURL string `yaml:"feed_url"` + Filters []Filter `yaml:"filters"` +} + +// Downloads iCal feed from the URL and applies filtering rules +func (calendarConfig CalendarConfig) fetch() ([]byte, error) { + + // get the iCal feed + slog.Debug("Fetching iCal feed", "url", calendarConfig.FeedURL) + resp, err := http.Get(calendarConfig.FeedURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + feedData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // parse calendar + cal, err := ics.ParseCalendar(strings.NewReader(string(feedData))) + if err != nil { + return nil, err + } + + // process filters + if len(calendarConfig.Filters) > 0 { + slog.Debug("Processing filters", "calendar", calendarConfig.Name) + for _, event := range cal.Events() { + if !calendarConfig.ProcessEvent(event) { + cal.RemoveEvent(event.Id()) + } + } + slog.Debug("Filter processing completed", "calendar", calendarConfig.Name) + } else { + slog.Debug("No filters to evaluate", "calendar", calendarConfig.Name) + } + + // serialize output + var buf bytes.Buffer + err = cal.SerializeTo(&buf) + if err != nil { + return nil, err + } + + // return + return buf.Bytes(), nil +} + +// Evaluate the filters for a calendar against a given VEvent and +// perform any transformations directly to the VEvent (pointer) +// This function returns false if an event should be deleted +func (calendarConfig CalendarConfig) ProcessEvent(event *ics.VEvent) bool { + + // Get the Summary (the "title" of the event) + // In case we cannot parse the event summary it should get dropped + summary := event.GetProperty(ics.ComponentPropertySummary) // summary only for logging + if summary == nil { + return false + } + + // Iterate through the Filter rules + for id, filter := range calendarConfig.Filters { + + // Does the filter match the event? + if filter.matchesEvent(*event) { + slog.Debug("Filter match found", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value) + + // The event should get dropped if RemoveEvent is set + if filter.RemoveEvent { + slog.Debug("Event to be removed, no more rules will be processed", "action", "DELETE", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value) + return false + } + + // Apply transformation rules to event + filter.transformEvent(event) + + // Check if we should stop processing rules + if filter.Stop { + slog.Debug("Stop option is set, no more rules will be processed", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value) + return true + } + } + } + + // Keep event by default if all Filter rules are processed + slog.Debug("Rule processing complete, event will be kept", "rule_id", nil, "event_summary", summary.Value) + return true + +} + +// Filter definition +type Filter struct { + Description string `yaml:"description"` + RemoveEvent bool `yaml:"remove"` + Stop bool `yaml:"stop"` + Match EventMatchRules `yaml:"match"` + Transform EventTransformRules `yaml:"transform"` +} + +// Returns true if a VEvent matches the Filter conditions +func (filter Filter) matchesEvent(event ics.VEvent) bool { + + // If an event property is not defined golang-ical returns a nil pointer + + // Get event Summary - only used for debug logging + eventSummary := event.GetProperty(ics.ComponentPropertySummary) + if eventSummary == nil { + slog.Warn("Unable to process event summary. Event will be dropped") + return false // never match if VEvent has no summary + } + + // Check Summary filters against VEvent + if filter.Match.Summary.hasConditions() { + if !filter.Match.Summary.matchesString(eventSummary.Value) { + return false + } + } + + // Check Description filters against VEvent + if filter.Match.Description.hasConditions() { + eventDescription := event.GetProperty(ics.ComponentPropertyDescription) + if eventDescription == nil { + slog.Debug("Event has no Description so cannot not match filter", "event_summary", eventSummary.Value, "filter", filter.Description) + return false // if VEvent has no description it cannot match filter + } + if !filter.Match.Description.matchesString(eventDescription.Value) { + slog.Debug("Event Description does not match filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description) + return false // event doesn't match + } + } + + // Check Description filters against VEvent + if filter.Match.Location.hasConditions() { + eventLocation := event.GetProperty(ics.ComponentPropertyLocation) + if eventLocation == nil { + slog.Warn("Event has no Location so cannot match filter", "event_summary", eventSummary.Value, "filter", filter.Description) + return false // if VEvent has no location it cannot match filter + } + if !filter.Match.Location.matchesString(eventLocation.Value) { + slog.Debug("Event Location does not match filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description) + return false // event doesn't match + + } + } + + // VEvent must match if we get here + slog.Debug("Event matches filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description) + return true +} + +// Applies filter transformations to a VEvent pointer +func (filter Filter) transformEvent(event *ics.VEvent) { + + // Summary transformations + if filter.Transform.Summary.Remove { + event.SetSummary("") + } else if filter.Transform.Summary.Replace != "" { + event.SetSummary(filter.Transform.Summary.Replace) + } + + // Description transformations + if filter.Transform.Description.Remove { + event.SetDescription("") + } else if filter.Transform.Description.Replace != "" { + event.SetDescription(filter.Transform.Description.Replace) + } +} + +// EventMatchRules contains VEvent properties that user can match against +type EventMatchRules struct { + Summary StringMatchRule `yaml:"summary"` + Description StringMatchRule `yaml:"description"` + Location StringMatchRule `yaml:"location"` +} + +// StringMatchRule defines match rules for VEvent properties with string values +type StringMatchRule struct { + Contains string `yaml:"contains"` + Prefix string `yaml:"prefix"` + Suffix string `yaml:"suffix"` + RegexMatch string `yaml:"regex"` +} + +// Returns true if StringMatchRule has any conditions +func (smr StringMatchRule) hasConditions() bool { + return smr.Contains != "" || + smr.Prefix != "" || + smr.Suffix != "" || + smr.RegexMatch != "" +} + +// Returns true if a given string (data) matches ALL StringMatchRule conditions +func (smr StringMatchRule) matchesString(data string) bool { + // check contains if set + if smr.Contains != "" { + if data == "" || !strings.Contains(data, smr.Contains) { + return false + } + } + // check prefix if set + if smr.Prefix != "" { + if data == "" || !strings.HasPrefix(data, smr.Prefix) { + return false + } + } + // check suffix if set + if smr.Suffix != "" { + if data == "" || !strings.HasSuffix(data, smr.Suffix) { + return false + } + } + // check regex match if set + if smr.RegexMatch != "" { + match, err := regexp.MatchString(data, smr.RegexMatch) + if err != nil { + slog.Warn("error processing regex rule", "value", smr.RegexMatch) + return false // regex error is considered a failure to match + } + if !match { + return false // regex didn't match + } + } + return true +} + +// EventTransformRules contains VEvent properties that user can modify +type EventTransformRules struct { + Summary StringTransformRule `yaml:"summary"` + Description StringTransformRule `yaml:"description"` + Location StringTransformRule `yaml:"location"` +} + +// StringTransformRule defines changes for VEvent properties with string values +type StringTransformRule struct { + Replace string `yaml:"replace"` + Remove bool `yaml:"remove"` +} diff --git a/charts/ical-filter-proxy/.helmignore b/charts/ical-filter-proxy/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/charts/ical-filter-proxy/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/ical-filter-proxy/Chart.yaml b/charts/ical-filter-proxy/Chart.yaml new file mode 100644 index 0000000..0834935 --- /dev/null +++ b/charts/ical-filter-proxy/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +name: ical-filter-proxy +description: iCal proxy with support for user-defined filtering rules +type: application +version: 0.1.0 +appVersion: "0.1.0" +maintainers: + - name: Yung Wood diff --git a/charts/ical-filter-proxy/templates/NOTES.txt b/charts/ical-filter-proxy/templates/NOTES.txt new file mode 100644 index 0000000..a626be0 --- /dev/null +++ b/charts/ical-filter-proxy/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "ical-filter-proxy.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "ical-filter-proxy.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "ical-filter-proxy.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "ical-filter-proxy.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/charts/ical-filter-proxy/templates/_helpers.tpl b/charts/ical-filter-proxy/templates/_helpers.tpl new file mode 100644 index 0000000..5c07908 --- /dev/null +++ b/charts/ical-filter-proxy/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "ical-filter-proxy.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "ical-filter-proxy.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "ical-filter-proxy.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "ical-filter-proxy.labels" -}} +helm.sh/chart: {{ include "ical-filter-proxy.chart" . }} +{{ include "ical-filter-proxy.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "ical-filter-proxy.selectorLabels" -}} +app.kubernetes.io/name: {{ include "ical-filter-proxy.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "ical-filter-proxy.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "ical-filter-proxy.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/ical-filter-proxy/templates/config.yaml b/charts/ical-filter-proxy/templates/config.yaml new file mode 100644 index 0000000..bac6e3a --- /dev/null +++ b/charts/ical-filter-proxy/templates/config.yaml @@ -0,0 +1,15 @@ +{{- if .Values.config.enabled -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "ical-filter-proxy.fullname" . }}-config + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} +data: + config.yaml: | + {{- if .Values.config.insecure }} + insecure: true + {{- end }} + calendars: +{{ .Values.config.calendars | toYaml | indent 6 }} +{{- end }} diff --git a/charts/ical-filter-proxy/templates/deployment.yaml b/charts/ical-filter-proxy/templates/deployment.yaml new file mode 100644 index 0000000..46ae7c8 --- /dev/null +++ b/charts/ical-filter-proxy/templates/deployment.yaml @@ -0,0 +1,79 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ical-filter-proxy.fullname" . }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "ical-filter-proxy.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "ical-filter-proxy.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: {{ .Values.args }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + {{- if .Values.config.enabled }} + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + {{- end }} + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + {{- if .Values.config.enabled }} + - name: config + configMap: + name: {{ include "ical-filter-proxy.fullname" . }}-config + {{- end }} + {{- with .Values.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/ical-filter-proxy/templates/hpa.yaml b/charts/ical-filter-proxy/templates/hpa.yaml new file mode 100644 index 0000000..ee955cd --- /dev/null +++ b/charts/ical-filter-proxy/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "ical-filter-proxy.fullname" . }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "ical-filter-proxy.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/charts/ical-filter-proxy/templates/ingress.yaml b/charts/ical-filter-proxy/templates/ingress.yaml new file mode 100644 index 0000000..99f59e7 --- /dev/null +++ b/charts/ical-filter-proxy/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "ical-filter-proxy.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/ical-filter-proxy/templates/service.yaml b/charts/ical-filter-proxy/templates/service.yaml new file mode 100644 index 0000000..0d36a9c --- /dev/null +++ b/charts/ical-filter-proxy/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "ical-filter-proxy.fullname" . }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "ical-filter-proxy.selectorLabels" . | nindent 4 }} diff --git a/charts/ical-filter-proxy/templates/serviceaccount.yaml b/charts/ical-filter-proxy/templates/serviceaccount.yaml new file mode 100644 index 0000000..0c954e3 --- /dev/null +++ b/charts/ical-filter-proxy/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "ical-filter-proxy.serviceAccountName" . }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/ical-filter-proxy/values.yaml b/charts/ical-filter-proxy/values.yaml new file mode 100644 index 0000000..3ff2019 --- /dev/null +++ b/charts/ical-filter-proxy/values.yaml @@ -0,0 +1,119 @@ +replicaCount: 1 + +image: + repository: yungwood/ical-filter-proxy + pullPolicy: IfNotPresent + tag: "" # defaults to chart appVersion + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +args: [] + +config: + enabled: false # enable to generate config from values (example below) + # calendars: + # - name: outlook + # token: "changeme" + # feed_url: "https://outlook.office365.com/owa/calendar/.../reachcalendar.ics" + # filters: + # - description: "Remove cancelled events" + # remove: true + # match: + # summary: + # prefix: "Canceled: " + # - description: "Remove optional events" + # remove: true + # match: + # summary: + # prefix: "[Optional]" + # - description: "Remove public holidays" + # remove: true + # match: + # summary: + # regex_match: ".*[Pp]ublic [Hh]oliday.*" + # insecure: false + +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: + capabilities: + drop: + - ALL + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 3000 + +service: + type: ClusterIP + port: 8080 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: ical-filter-proxy.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: ical-filter-proxy-tls + # hosts: + # - ical-filter-proxy.local + +resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +livenessProbe: + httpGet: + path: /liveness + port: http +readinessProbe: + httpGet: + path: /readiness + port: http + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..94401d9 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,26 @@ +--- +services: + ical-filter-proxy: + image: yungwood/ical-filter-proxy:latest + container_name: ical-filter-proxy + # # specify additional command arguments + # command: --debug + ports: + - "8080:8080" + volumes: + - ./config.yaml:/app/config.yaml + restart: always + +## the below example might be used with traefik docker provider +## https://doc.traefik.io/traefik/providers/docker/ +# labels: +# traefik.enable: true +# traefik.http.routers.ical.rule: "Host(`ical.mydomain.com`)" +# traefik.http.routers.ical.entrypoints: web +# traefik.http.services.ical.loadbalancer.server.port: 8080 +# networks: +# - traefik +# networks: +# traefik: +# name: traefik +# external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6cb4cd0 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/yungwood/ical-filter-proxy + +go 1.22.5 + +require ( + github.com/arran4/golang-ical v0.3.1 + github.com/gookit/color v1.5.4 // indirect + github.com/gookit/goutil v0.6.15 // indirect + github.com/gookit/gsr v0.1.0 // indirect + github.com/gookit/slog v0.5.6 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f7f1f36 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk= +github.com/arran4/golang-ical v0.3.1/go.mod h1:LZWxF8ZIu/sjBVUCV0udiVPrQAgq3V0aa0RfbO99Qkk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gookit/goutil v0.6.15/go.mod h1:qdKdYEHQdEtyH+4fNdQNZfJHhI0jUZzHxQVAV3DaMDY= +github.com/gookit/gsr v0.1.0/go.mod h1:7wv4Y4WCnil8+DlDYHBjidzrEzfHhXEoFjEA0pPPWpI= +github.com/gookit/slog v0.5.6/go.mod h1:RfIwzoaQ8wZbKdcqG7+3EzbkMqcp2TUn3mcaSZAw2EQ= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..c292f0c Binary files /dev/null and b/logo.png differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..94be1c4 --- /dev/null +++ b/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +var Version = "development" + +// this struct used to parse config.yaml +type Config struct { + Calendars []CalendarConfig `yaml:"calendars"` + AllowUnsafe bool `yaml:"unsafe"` +} + +// This function loads the configuration file and does some basic validation +// Returns false if the config is not valid or an error occurs +func (config *Config) LoadConfig(file string) bool { + data, err := os.ReadFile(file) + if err != nil { + slog.Error("Unable to open config file! You can use -config to specify a different file", "file", file) + return false + } + err = yaml.Unmarshal(data, &config) + if err != nil { + slog.Error("Error while unmarshalling yaml! Check config file is valid", "file", file) + return false + } + + // ensure calendars exist + if len(config.Calendars) == 0 { + slog.Error("No calendars found! Configuration should define at least one calendar") + return false + } + + // validate calendar configs + for _, calendarConfig := range config.Calendars { + + // check if url seems valid + if !strings.HasPrefix(calendarConfig.FeedURL, "http://") && !strings.HasPrefix(calendarConfig.FeedURL, "https://") { + slog.Debug("Calendar URL must begin with http:// or https://", "calendar", calendarConfig.Name, "feed_url", len(calendarConfig.Filters)) + return false + } + + // Check to see if auth is disabled (token not set) + // If so print a warning message and make sure unsafe is enabled in config + if len(calendarConfig.Token) == 0 { + slog.Warn("Calendar has no token set. Authentication will be disabled", "calendar", calendarConfig.Name) + if !config.AllowUnsafe { + slog.Error("Calendar cannot have authentication disabled without unsafe optionenabled in the configuration", "calendar", calendarConfig.Name) + return false + } + } + + // Print a warning if the calendar has no filters + if len(calendarConfig.Filters) == 0 { + slog.Warn("Calendar has no filters and will be proxy-only", "calendar", calendarConfig.Name) + break + } + + } + + return true // config is parsed successfully + +} + +func main() { + + // command-line args + var ( + configFile string + debugLogging bool + jsonLogging bool + listenPort int + validateConfig bool + printVersion bool + ) + flag.StringVar(&configFile, "config", "config.yaml", "config file") + flag.BoolVar(&debugLogging, "debug", false, "enable debug logging") + flag.BoolVar(&printVersion, "version", false, "print version and exit") + flag.BoolVar(&jsonLogging, "json", false, "output logging in JSON format") + flag.IntVar(&listenPort, "port", 8080, "listening port for api") + flag.BoolVar(&validateConfig, "validate", false, "validate config and exit") + flag.Parse() + + // print version and exit + if printVersion { + fmt.Println("version:", Version) + os.Exit(0) + } + + // setup logging options + loggingLevel := slog.LevelInfo // default loglevel + if debugLogging { + loggingLevel = slog.LevelDebug // debug logging enabled + } + opts := &slog.HandlerOptions{ + Level: loggingLevel, + } + + // create json or text logger based on args + var logger *slog.Logger + if jsonLogging { + logger = slog.New(slog.NewJSONHandler(os.Stdout, opts)) + } else { + logger = slog.New(slog.NewTextHandler(os.Stdout, opts)) + } + slog.SetDefault(logger) + + // load configuration + slog.Debug("reading config", "configFile", configFile) + var config Config + if !config.LoadConfig(configFile) { + os.Exit(1) // fail if config is not valid + } + slog.Debug("loaded config") + + // print a message and exit if validate arg was specified + if validateConfig { + slog.Info("configuration was validated successfully") + os.Exit(0) + } + + // iterate through calendars in the config and setup a handler for each + // todo: consider refactor to route requests dynamically? + for _, calendarConfig := range config.Calendars { + + // configure HTTP endpoint + httpPath := "/calendars/" + calendarConfig.Name + "/feed" + slog.Debug("Configuring endpoint", "calendar", calendarConfig.Name, "http_path", httpPath) + http.HandleFunc(httpPath, func(w http.ResponseWriter, r *http.Request) { + + slog.Debug("Received request for calendar", "http_path", httpPath, "calendar", calendarConfig.Name, "client_ip", r.RemoteAddr) + + // validate token + token := r.URL.Query().Get("token") + if token != calendarConfig.Token { + slog.Warn("Unauthorized access attempt", "client_ip", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // fetch and filter upstream calendar + feed, err := calendarConfig.fetch() + if err != nil { + slog.Error("Error fetching and filtering feed", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // return calendar + w.Header().Set("Content-Type", "text/calendar") + _, err = w.Write(feed) + if err != nil { + slog.Error("Error writing response", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + slog.Info("Calendar request processed", "http_path", httpPath, "calendar", calendarConfig.Name, "client_ip", r.RemoteAddr) + }) + + } + + // add a readiness and liveness check endpoint (return blank 200 OK response) + http.HandleFunc("/liveness", func(w http.ResponseWriter, r *http.Request) {}) + http.HandleFunc("/readiness", func(w http.ResponseWriter, r *http.Request) {}) + + // start the webserver + slog.Info("Starting web server", "port", listenPort) + if err := http.ListenAndServe(":"+strconv.Itoa(listenPort), nil); err != nil { + slog.Error("Error starting web server", "error", err) + } + +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..4107bab --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "baseBranches": ["develop"] +} \ No newline at end of file