From 09af83576a877eda40512bfba30b47a1b13061fb Mon Sep 17 00:00:00 2001 From: Rutger Broekhoff Date: Wed, 13 Nov 2024 20:47:40 +0100 Subject: Initial (public) commit --- .gitattributes | 1 + .gitignore | 5 +++ config.go | 74 +++++++++++++++++++++++++++++++++ flake.lock | 58 ++++++++++++++++++++++++++ flake.nix | 37 +++++++++++++++++ go.mod | 13 ++++++ go.sum | 8 ++++ icalproxy.go | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++ module/default.nix | 73 +++++++++++++++++++++++++++++++++ 9 files changed, 386 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 config.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 icalproxy.go create mode 100644 module/default.nix diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b72521c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +/vendor/* linguist-vendored diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cb80c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +.vscode/ + +# go build +icalproxy diff --git a/config.go b/config.go new file mode 100644 index 0000000..d9a2e30 --- /dev/null +++ b/config.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "log" + "net/url" + "os" + "regexp" +) + +type userToken struct { + Hash []byte `json:"hash"` + Salt []byte `json:"salt"` +} + +type config struct { + CalendarURL url.URL `json:"calendar_url"` + Ignore ignoreRules `json:"ignore"` + Port string `json:"port"` + UserTokens map[string]userToken `json:"user_tokens"` +} + +type ignoreRules struct { + LocationRegexes []regexp.Regexp `json:"location_regexes"` + SummaryRegexes []regexp.Regexp `json:"summary_regexes"` +} + +func printConfig(cfg *config) { + b64 := base64.StdEncoding + + log.Print("Loaded configuration: ") + log.Print(" HTTP Port: ", cfg.Port) + log.Print(" User Tokens:") + for user, token := range cfg.UserTokens { + log.Print(" User ", user, ":") + log.Print(" Hash: ", b64.EncodeToString(token.Hash)) + log.Print(" Salt: ", b64.EncodeToString(token.Salt)) + } + if len(cfg.UserTokens) == 0 { + log.Print(" ") + } + log.Print(" Ignoring:") + for _, entry := range cfg.Ignore.LocationRegexes { + log.Printf(" Events with locations matching %s", entry.String()) + } + for _, entry := range cfg.Ignore.SummaryRegexes { + log.Printf(" Events with summaries matching %s", entry.String()) + } + if len(cfg.Ignore.LocationRegexes)+len(cfg.Ignore.SummaryRegexes) == 0 { + log.Printf(" ") + } +} + +func loadConfigFrom(filename string) config { + bytes, err := os.ReadFile(filename) + if err != nil { + log.Fatal("Could not read config file (at ", filename, ")") + } + var cfg config + if err = json.Unmarshal(bytes, &cfg); err != nil { + log.Fatalln("Could not parse config file:", err) + } + return cfg +} + +func loadConfig() config { + configFile := os.Getenv("CONFIG_FILE") + if configFile == "" { + log.Fatal("Environment variable CONFIG_FILE not specified") + } + log.Print("Loading configuration from ", configFile) + return loadConfigFrom(configFile) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..1bcf97a --- /dev/null +++ b/flake.lock @@ -0,0 +1,58 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", + "revCount": 88, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/numtide/flake-utils/0.1.88%2Brev-4022d587cbbfd70fe950c1e2083a02621806a725/018c340d-3287-7c66-818b-f2f646a808e3/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/numtide/flake-utils/0.1.88.tar.gz" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1701952659, + "narHash": "sha256-TJv2srXt6fYPUjxgLAL0cy4nuf1OZD4KuA1TrCiQqg0=", + "rev": "b4372c4924d9182034066c823df76d6eaf1f4ec4", + "revCount": 552856, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2311.552856%2Brev-b4372c4924d9182034066c823df76d6eaf1f4ec4/018c47c2-c604-7ad3-b455-4b2ad1c90554/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/NixOS/nixpkgs/0.2311.%2A.tar.gz" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f8ca910 --- /dev/null +++ b/flake.nix @@ -0,0 +1,37 @@ +{ + inputs = { + nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.2311.*.tar.gz"; + flake-utils.url = "https://flakehub.com/f/numtide/flake-utils/0.1.88.tar.gz"; + }; + + outputs = { self, nixpkgs, flake-utils, ... }@inputs: + { + nixosModules = rec { + icalproxy = import ./module self; + default = icalproxy; + }; + } // flake-utils.lib.eachDefaultSystem + (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ ]; + }; + + icalproxy = pkgs.buildGoModule { + name = "icalproxy"; + src = ./.; + vendorHash = "sha256-l/PQPEZC99umlSr6PBn/dXn2mZN4qf8YMBE8ENOm97I="; + }; + in + { + packages.icalproxy = icalproxy; + packages.default = self.packages.${system}.icalproxy; + + devShells.default = pkgs.mkShell { + inputsFrom = [ icalproxy ]; + }; + + formatter = pkgs.nixpkgs-fmt; + }); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d89d396 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/rutgerbrf/icalproxy + +go 1.21 + +require ( + github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f + golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 +) + +require ( + github.com/teambition/rrule-go v1.7.2 // indirect + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aede5ad --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ= +github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ= +github.com/teambition/rrule-go v1.7.2 h1:goEajFWYydfCgavn2m/3w5U+1b3PGqPUHx/fFSVfTy0= +github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/icalproxy.go b/icalproxy.go new file mode 100644 index 0000000..cf09e5d --- /dev/null +++ b/icalproxy.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "log" + "net/http" + "net/url" + "time" + + "github.com/emersion/go-ical" + "golang.org/x/crypto/argon2" +) + +func main() { + cfg := loadConfig() + printConfig(&cfg) + + handler := handler{ignore: cfg.Ignore, tokens: cfg.UserTokens} + + mux := http.ServeMux{} + mux.HandleFunc("/", handler.handle) + server := http.Server{ + Addr: ":" + cfg.Port, + Handler: &mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 30 * time.Second, + ReadHeaderTimeout: 2 * time.Second, + } + + log.Println("Listening on", cfg.Port) + log.Fatal(server.ListenAndServe()) +} + +type handler struct { + ignore ignoreRules + tokens map[string]userToken + calURL url.URL +} + +func (h handler) makeTokenURL(token string) string { + newURL := h.calURL + query := newURL.Query() + query.Set("token", token) + newURL.RawQuery = query.Encode() + return newURL.String() +} + +func (h handler) handle(w http.ResponseWriter, r *http.Request) { + brightspaceToken := r.URL.Query().Get("brightspace_token") + proxyUserID := r.URL.Query().Get("proxy_user_id") + proxyToken := r.URL.Query().Get("proxy_token") + if !userOK(h.tokens, proxyUserID, []byte(proxyToken)) { + http.Error(w, "Bad proxy_user_id or proxy_token", http.StatusUnauthorized) + return + } + + resp, err := http.Get(h.makeTokenURL(brightspaceToken)) + if err != nil { + http.Error(w, "Error sending request to Brightspace", http.StatusInternalServerError) + return + } + + cal, err := ical.NewDecoder(resp.Body).Decode() + if err != nil { + http.Error(w, "Could not decode iCal", http.StatusInternalServerError) + return + } + + filter(h.ignore, cal.Component) + + if err = ical.NewEncoder(w).Encode(cal); err != nil { + log.Println("Error writing calendar:", err) + } +} + +func filter(ignore ignoreRules, c *ical.Component) { + j := 0 + for _, child := range c.Children { + keep := true + + if child.Name == ical.CompEvent { + if location := child.Props.Get(ical.PropLocation); location != nil { + for _, locationRule := range ignore.LocationRegexes { + if locationRule.MatchString(location.Value) { + keep = false + } + } + } + if summary := child.Props.Get(ical.PropSummary); summary != nil { + for _, summaryRule := range ignore.SummaryRegexes { + if summaryRule.MatchString(summary.Value) { + keep = false + } + } + } + } + + if keep { + c.Children[j] = child + j++ + } + } + c.Children = c.Children[:j] +} + +func userOK(tokens map[string]userToken, id string, token []byte) bool { + info, ok := tokens[id] + if !ok { + return false + } + return bytes.Compare(hash(token, info.Salt), info.Hash) == 0 +} + +func hash(token []byte, salt []byte) []byte { + return argon2.IDKey(token, salt, 1, 64*1024, 4, 32) +} diff --git a/module/default.nix b/module/default.nix new file mode 100644 index 0000000..774a361 --- /dev/null +++ b/module/default.nix @@ -0,0 +1,73 @@ +flake: { lib, config, pkgs, ... }: +with lib; +let + inherit (flake.packages.${pkgs.stdenv.hostPlatform.system}) icalproxy; + + cfg = config.services.icalproxy; +in { + options.services.icalproxy = with types; { + enable = mkEnableOption "icalproxy"; + calendarUrl = mkOption { + type = str; + }; + ignore = mkOption { + type = submodule { + options = { + locationRegexes = mkOption { + type = listOf str; + }; + summaryRegexes = mkOption { + type = listOf str; + }; + }; + }; + }; + port = mkOption { + type = str; + }; + userTokens = mkOption { + type = attrsOf (submodule { + options = { + hash = mkOption { + type = str; + }; + salt = mkOption { + type = str; + }; + }; + }); + }; + }; + + config = mkIf cfg.enable { + users.users.icalproxy = { + description = "icalproxy service user"; + isSystemUser = true; + group = "icalproxy"; + }; + + users.groups.icalproxy = {}; + + systemd.services.icalproxy = { + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + environment = { + CONFIG_FILE = pkgs.writeText "icalproxy-config.json" (builtins.toJSON { + calendar_url = cfg.calendarUrl; + ignore = { + location_regexes = cfg.ignore.locationRegexes; + summary_regexes = cfg.ignore.summaryRegexes; + }; + port = cfg.port; + user_tokens = cfg.userTokens; + }); + }; + serviceConfig = { + User = config.users.users.icalproxy.name; + Group = config.users.users.icalproxy.group; + Restart = "always"; + ExecStart = "${lib.getBin icalproxy}/bin/icalproxy"; + }; + }; + }; +} -- cgit v1.2.3