initial commit

This commit is contained in:
Yung Wood
2024-08-18 14:41:10 +00:00
parent 68ba3cfb38
commit cb79e55c9e
22 changed files with 1238 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/ical-filter-proxy
/config.yaml

19
.gitlab-ci.yml Normal file
View File

@@ -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

37
Dockerfile Normal file
View File

@@ -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"]

213
README.md Normal file
View File

@@ -0,0 +1,213 @@
<!-- PROJECT LOGO -->
<br />
<div align="center">
<a href="https://github.com/yungwood/ical-filter-proxy">
<img src="logo.png" alt="Logo" width="120" height="120">
</a>
<h3 align="center">iCal Filter Proxy</h3>
<p align="center">
iCal proxy with support for user-defined filtering rules
</p>
</div>
## 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.

259
calendar.go Normal file
View File

@@ -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"`
}

View File

@@ -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/

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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: {}

26
docker-compose.yaml Normal file
View File

@@ -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

18
go.mod Normal file
View File

@@ -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
)

29
go.sum Normal file
View File

@@ -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=

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

182
main.go Normal file
View File

@@ -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)
}
}

4
renovate.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"baseBranches": ["develop"]
}