diff --git a/cmd/csaf_provider/config.go b/cmd/csaf_provider/config.go new file mode 100644 index 0000000..c048721 --- /dev/null +++ b/cmd/csaf_provider/config.go @@ -0,0 +1,92 @@ +package main + +import ( + "os" + "strings" + + "github.com/BurntSushi/toml" +) + +const ( + configEnv = "CSAF_CONFIG" + defaultConfigPath = "/usr/lib/casf/config.toml" + defaultFolder = "/var/www/" + defaultWeb = "/var/www/html" + defaultPGPURL = "http://pgp.mit.edu/pks/lookup?search=${KEY}&op=index" +) + +type config struct { + Key string `toml:"key"` + Folder string `toml:"folder"` + Web string `toml:"web"` + TLPs []tlp `toml:"tlps"` + UploadSignature bool `toml:"upload_signature"` + PGPURL string `toml:"pgp_url"` + Domain string `toml:"domain"` +} + +type tlp string + +const ( + tlpCSAF tlp = "csaf" + tlpWhite tlp = "white" + tlpGreen tlp = "green" + tlpAmber tlp = "amber" + tlpRed tlp = "red" +) + +func (t tlp) valid() bool { + switch t { + case tlpCSAF, tlpWhite, tlpGreen, tlpAmber, tlpRed: + return true + default: + return false + } +} + +func (t *tlp) UnmarshalText(text []byte) error { + if s := tlp(text); s.valid() { + *t = s + return nil + } + return nil +} + +func (cfg *config) GetPGPURL(key string) string { + return strings.ReplaceAll(cfg.PGPURL, "${KEY}", key) +} + +func loadConfig() (*config, error) { + path := os.Getenv(configEnv) + if path == "" { + path = defaultConfigPath + } + var cfg config + if _, err := toml.DecodeFile(path, &cfg); err != nil { + return nil, err + } + + // Preset defaults + + if cfg.Folder == "" { + cfg.Folder = defaultFolder + } + + if cfg.Web == "" { + cfg.Web = defaultWeb + } + + if cfg.Domain == "" { + cfg.Domain = "http://" + os.Getenv("SERVER_NAME") + } + + if cfg.TLPs == nil { + cfg.TLPs = []tlp{tlpCSAF, tlpWhite, tlpGreen, tlpAmber, tlpRed} + } + + if cfg.PGPURL == "" { + cfg.PGPURL = defaultPGPURL + } + + return &cfg, nil +} diff --git a/cmd/csaf_provider/controller.go b/cmd/csaf_provider/controller.go new file mode 100644 index 0000000..58e4864 --- /dev/null +++ b/cmd/csaf_provider/controller.go @@ -0,0 +1,399 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "crypto/sha512" + "embed" + "encoding/json" + "errors" + "fmt" + "hash" + "html/template" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/ProtonMail/gopenpgp/v2/crypto" + + "github.com/intevation/csaf_trusted/csaf" +) + +const dateFormat = time.RFC3339 + +//go:embed tmpl +var tmplFS embed.FS + +type controller struct { + cfg *config + tmpl *template.Template +} + +func newController(cfg *config) (*controller, error) { + + c := controller{cfg: cfg} + var err error + + if c.tmpl, err = template.ParseFS(tmplFS, "tmpl/*.html"); err != nil { + return nil, err + } + + return &c, nil +} + +func (c *controller) bind(pim *pathInfoMux) { + pim.handleFunc("/", c.index) + pim.handleFunc("/upload", c.upload) + pim.handleFunc("/create", c.create) +} + +func (c *controller) render(rw http.ResponseWriter, tmpl string, arg interface{}) { + rw.Header().Set("Content-type", "text/html; charset=utf-8") + if err := c.tmpl.ExecuteTemplate(rw, tmpl, arg); err != nil { + log.Printf("warn: %v\n", err) + } +} + +func (c *controller) failed(rw http.ResponseWriter, tmpl string, err error) { + rw.Header().Set("Content-type", "text/html; charset=utf-8") + result := map[string]interface{}{"Error": err} + if err := c.tmpl.ExecuteTemplate(rw, tmpl, result); err != nil { + log.Printf("warn: %v\n", err) + } +} + +func (c *controller) index(rw http.ResponseWriter, r *http.Request) { + c.render(rw, "index.html", map[string]interface{}{ + "Config": c.cfg, + }) +} + +func (c *controller) create(rw http.ResponseWriter, r *http.Request) { + if err := ensureFolders(c.cfg); err != nil { + c.failed(rw, "create.html", err) + return + } + c.render(rw, "create.html", nil) +} + +func (c *controller) tlpParam(r *http.Request) (tlp, error) { + t := tlp(strings.ToLower(r.FormValue("tlp"))) + for _, x := range c.cfg.TLPs { + if x == t { + return t, nil + } + } + return "", fmt.Errorf("unsupported TLP type '%s'", t) +} + +func cleanFileName(s string) string { + s = strings.ReplaceAll(s, `/`, ``) + s = strings.ReplaceAll(s, `\`, ``) + r := regexp.MustCompile(`\.{2,}`) + s = r.ReplaceAllString(s, `.`) + return s +} + +func loadCSAF(r *http.Request) (string, []byte, error) { + file, handler, err := r.FormFile("csaf") + if err != nil { + return "", nil, err + } + defer file.Close() + + var buf bytes.Buffer + lr := io.LimitReader(file, 10*1024*1024) + if _, err := io.Copy(&buf, lr); err != nil { + return "", nil, err + } + return cleanFileName(handler.Filename), buf.Bytes(), nil +} + +func writeHash(fname, name string, h hash.Hash, data []byte) error { + + if _, err := io.Copy(h, bytes.NewReader(data)); err != nil { + return err + } + + f, err := os.Create(fname) + if err != nil { + return err + } + fmt.Fprintf(f, "%x %s\n", h.Sum(nil), name) + return f.Close() +} + +func (c *controller) loadCryptoKey() (*crypto.Key, error) { + f, err := os.Open(c.cfg.Key) + if err != nil { + return nil, err + } + defer f.Close() + return crypto.NewKeyFromArmoredReader(f) +} + +func (c *controller) handleSignature(r *http.Request, data []byte) (string, string, error) { + + // Either way ... we need the key. + key, err := c.loadCryptoKey() + if err != nil { + return "", "", err + } + + fingerprint := key.GetFingerprint() + + // Was the signature given via request? + if c.cfg.UploadSignature { + sigText := r.FormValue("signature") + if sigText == "" { + return "", "", errors.New("missing signature in request") + } + + pgpSig, err := crypto.NewPGPSignatureFromArmored(sigText) + if err != nil { + return "", "", err + } + + // Use as public key + signRing, err := crypto.NewKeyRing(key) + if err != nil { + return "", "", err + } + + if err := signRing.VerifyDetached( + crypto.NewPlainMessage(data), + pgpSig, crypto.GetUnixTime(), + ); err != nil { + return "", "", err + } + + return sigText, fingerprint, nil + } + + // Sign ourself + + if passwd := r.FormValue("passphrase"); passwd != "" { + if key, err = key.Unlock([]byte(passwd)); err != nil { + return "", "", err + } + } + + // Use as private key + signRing, err := crypto.NewKeyRing(key) + if err != nil { + return "", "", err + } + + sig, err := signRing.SignDetached(crypto.NewPlainMessage(data)) + if err != nil { + return "", "", err + } + + armored, err := sig.GetArmored() + return armored, fingerprint, err +} + +func (c *controller) upload(rw http.ResponseWriter, r *http.Request) { + + newCSAF, data, err := loadCSAF(r) + if err != nil { + c.failed(rw, "upload.html", err) + return + } + + var content interface{} + if err := json.Unmarshal(data, &content); err != nil { + c.failed(rw, "upload.html", err) + return + } + + ex, err := newExtraction(content) + if err != nil { + c.failed(rw, "upload.html", err) + return + } + + t, err := c.tlpParam(r) + if err != nil { + c.failed(rw, "upload.html", err) + return + } + + // Extract real TLP from document. + if t == tlpCSAF { + if t = tlp(strings.ToLower(ex.tlpLabel)); !t.valid() || t == tlpCSAF { + c.failed( + rw, "upload.html", fmt.Errorf("not a valid TL: %s", ex.tlpLabel)) + return + } + } + + armored, fingerprint, err := c.handleSignature(r, data) + if err != nil { + c.failed(rw, "upload.html", err) + return + } + + if err := c.doTransaction(t, func(folder string, pmd *csaf.ProviderMetadata) error { + + year := strconv.Itoa(ex.currentReleaseDate.Year()) + + subDir := filepath.Join(folder, year) + + // Create folder if it does not exists. + if _, err := os.Stat(subDir); err != nil { + if os.IsNotExist(err) { + if err := os.Mkdir(subDir, 0755); err != nil { + return err + } + } else { + return err + } + } + + fname := filepath.Join(subDir, newCSAF) + + // Write the file itself. + if err := ioutil.WriteFile(fname, data, 0644); err != nil { + return err + } + + // Write SHA256 sum. + if err := writeHash(fname+".sha256", newCSAF, sha256.New(), data); err != nil { + return err + } + + // Write SHA512 sum. + if err := writeHash(fname+".sha512", newCSAF, sha512.New(), data); err != nil { + return err + } + + // Write signature. + if err := ioutil.WriteFile(fname+".asc", []byte(armored), 0644); err != nil { + return err + } + + if err := updateIndices( + folder, filepath.Join(year, newCSAF), + ex.currentReleaseDate, + ); err != nil { + return err + } + + // Take over publisher + // TODO: Check for conflicts. + pmd.Publisher = ex.publisher + + pmd.SetPGP(fingerprint, c.cfg.GetPGPURL(fingerprint)) + + return nil + }); err != nil { + c.failed(rw, "upload.html", err) + return + } + + result := map[string]interface{}{ + "Name": newCSAF, + "ReleaseDate": ex.currentReleaseDate.Format(dateFormat), + } + + c.render(rw, "upload.html", result) +} + +func (c *controller) doTransaction( + t tlp, + fn func(string, *csaf.ProviderMetadata) error, +) error { + + wellknown := filepath.Join(c.cfg.Web, ".well-known", "csaf") + + metadata := filepath.Join(wellknown, "provider-metadata.json") + + pmd, err := func() (*csaf.ProviderMetadata, error) { + f, err := os.Open(metadata) + if err != nil { + if os.IsNotExist(err) { + return csaf.NewProviderMetadata( + c.cfg.Domain + "/.wellknown/csaf/provider-metadata.json"), nil + } + return nil, err + } + defer f.Close() + return csaf.LoadProviderMetadata(f) + }() + + if err != nil { + return err + } + + webTLP := filepath.Join(wellknown, string(t)) + + oldDir, err := filepath.EvalSymlinks(webTLP) + if err != nil { + return err + } + + folderTLP := filepath.Join(c.cfg.Folder, string(t)) + + newDir, err := mkUniqDir(folderTLP) + if err != nil { + return err + } + + // Copy old content into new. + if err := deepCopy(newDir, oldDir); err != nil { + os.RemoveAll(newDir) + return err + } + + // Work with new folder. + if err := fn(newDir, pmd); err != nil { + os.RemoveAll(newDir) + return err + } + + // Write back provider metadata. + newMetaName, newMetaFile, err := mkUniqFile(metadata) + if err != nil { + os.RemoveAll(newDir) + return err + } + + if err := pmd.Save(newMetaFile); err != nil { + newMetaFile.Close() + os.Remove(newMetaName) + os.RemoveAll(newDir) + return err + } + + if err := newMetaFile.Close(); err != nil { + os.Remove(newMetaName) + os.RemoveAll(newDir) + return err + } + + if err := os.Rename(newMetaName, metadata); err != nil { + os.RemoveAll(newDir) + return err + } + + // Switch directories. + symlink := filepath.Join(newDir, string(t)) + if err := os.Symlink(newDir, symlink); err != nil { + os.RemoveAll(newDir) + return err + } + if err := os.Rename(symlink, webTLP); err != nil { + os.RemoveAll(newDir) + return err + } + + return os.RemoveAll(oldDir) +} diff --git a/cmd/csaf_provider/dir.go b/cmd/csaf_provider/dir.go new file mode 100644 index 0000000..89484e5 --- /dev/null +++ b/cmd/csaf_provider/dir.go @@ -0,0 +1,176 @@ +package main + +import ( + "errors" + "fmt" + "math/rand" + "os" + "path/filepath" + "strconv" + "time" +) + +func ensureFolders(c *config) error { + + wellknown, err := createWellknown(c) + if err != nil { + return err + } + + if err := createFeedFolders(c, wellknown); err != nil { + return err + } + + return createSecurity(c) +} + +func createSecurity(c *config) error { + security := filepath.Join(c.Web, "security.txt") + if _, err := os.Stat(security); err != nil { + if os.IsNotExist(err) { + f, err := os.Create(security) + if err != nil { + return err + } + fmt.Fprintf( + f, "CSAF: https://%s/.wellknown/csaf/provider-metadata.json\n", + c.Domain) + return f.Close() + } else { + return err + } + } + return nil +} + +func createFeedFolders(c *config, wellknown string) error { + for _, t := range c.TLPs { + if t == tlpCSAF { + continue + } + tlpLink := filepath.Join(wellknown, string(t)) + if _, err := filepath.EvalSymlinks(tlpLink); err != nil { + if os.IsNotExist(err) { + tlpFolder := filepath.Join(c.Folder, string(t)) + if tlpFolder, err = mkUniqDir(tlpFolder); err != nil { + return err + } + if err = os.Symlink(tlpFolder, tlpLink); err != nil { + return err + } + } else { + return err + } + } + } + return nil +} + +func createWellknown(c *config) (string, error) { + wellknown := filepath.Join(c.Web, ".well-known", "csaf") + + st, err := os.Stat(wellknown) + if err != nil { + if os.IsNotExist(err) { + if err := os.MkdirAll(wellknown, 0755); err != nil { + return "", err + } + } else { + return "", err + } + } else { + if !st.IsDir() { + return "", errors.New(".well-known/csaf is not a directory") + } + } + return wellknown, nil +} + +func deepCopy(dst, src string) error { + + stack := []string{dst, src} + + for len(stack) > 0 { + src = stack[len(stack)-1] + dst = stack[len(stack)-2] + stack = stack[:len(stack)-2] + + if err := func() error { + dir, err := os.Open(src) + if err != nil { + return err + } + defer dir.Close() + + // Use Readdir as we need no sorting. + files, err := dir.Readdir(-1) + if err != nil { + return err + } + + for _, f := range files { + nsrc := filepath.Join(src, f.Name()) + ndst := filepath.Join(dst, f.Name()) + if f.IsDir() { + // Create new sub dir + if err := os.Mkdir(ndst, 0755); err != nil { + return err + } + stack = append(stack, ndst, nsrc) + } else if f.Mode().IsRegular() { + // Create hard link. + if err := os.Link(nsrc, ndst); err != nil { + return err + } + } + } + return nil + }(); err != nil { + return err + } + } + + return nil +} + +func mkUniqFile(prefix string) (string, *os.File, error) { + var file *os.File + name, err := mkUniq(prefix, func(name string) error { + var err error + file, err = os.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + return err + }) + return name, file, err +} + +func mkUniqDir(prefix string) (string, error) { + return mkUniq(prefix, func(name string) error { return os.Mkdir(name, 0755) }) +} + +func mkUniq(prefix string, create func(string) error) (string, error) { + now := time.Now() + stamp := now.Format("-2006-01-02-150405") + name := prefix + stamp + err := create(name) + if err == nil { + return name, nil + } + if os.IsExist(err) { + rnd := rand.New(rand.NewSource(now.Unix())) + + for i := 0; i < 10000; i++ { + nname := name + "-" + strconv.FormatUint(uint64(rnd.Uint32()&0xff_ffff), 16) + err := create(nname) + if err == nil { + return nname, nil + } + if os.IsExist(err) { + continue + } + return "", err + } + return "", &os.PathError{Op: "mkuniq", Path: name, Err: os.ErrExist} + } + + return "", err +} diff --git a/cmd/csaf_provider/extract.go b/cmd/csaf_provider/extract.go new file mode 100644 index 0000000..d2a0b4d --- /dev/null +++ b/cmd/csaf_provider/extract.go @@ -0,0 +1,126 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "time" + + "github.com/PaesslerAG/gval" + "github.com/PaesslerAG/jsonpath" + + "github.com/intevation/csaf_trusted/csaf" +) + +const ( + idExpr = `$.document.tracking.id` + titleExpr = `$.document.title` + publisherExpr = `$.document.publisher` + initialReleaseDateExpr = `$.document.tracking.initial_release_date` + currentReleaseDateExpr = `$.document.tracking.current_release_date` + tlpLabelExpr = `$.document.distribution.tlp.label` + summaryExpr = `$.document.notes[? @.category=="summary" || @.type=="summary"].text` +) + +type extraction struct { + id string + title string + publisher *csaf.CSAFPublisher + initialReleaseDate time.Time + currentReleaseDate time.Time + summary string + tlpLabel string +} + +func newExtraction(content interface{}) (*extraction, error) { + + builder := gval.Full(jsonpath.Language()) + + e := new(extraction) + + for _, fn := range []func(*gval.Language, interface{}) error{ + extractText(idExpr, &e.id), + extractText(titleExpr, &e.title), + extractTime(currentReleaseDateExpr, &e.currentReleaseDate), + extractTime(initialReleaseDateExpr, &e.initialReleaseDate), + extractText(summaryExpr, &e.summary), + extractText(tlpLabelExpr, &e.tlpLabel), + e.extractPublisher, + } { + if err := fn(&builder, content); err != nil { + return nil, err + } + } + + return e, nil +} + +func extractText( + expr string, + store *string, +) func(*gval.Language, interface{}) error { + return func(builder *gval.Language, content interface{}) error { + eval, err := builder.NewEvaluable(expr) + if err != nil { + return err + } + s, err := eval(context.Background(), content) + if text, ok := s.(string); ok && err == nil { + *store = text + } + return nil + } +} + +func extractTime( + expr string, + store *time.Time, +) func(*gval.Language, interface{}) error { + return func(builder *gval.Language, content interface{}) error { + eval, err := builder.NewEvaluable(expr) + if err != nil { + return err + } + s, err := eval(context.Background(), content) + if err != nil { + return err + } + text, ok := s.(string) + if !ok { + return errors.New("not a string") + } + date, err := time.Parse(dateFormat, text) + if err == nil { + *store = date.UTC() + } + return err + } +} + +func (e *extraction) extractPublisher( + builder *gval.Language, + content interface{}, +) error { + eval, err := builder.NewEvaluable(publisherExpr) + if err != nil { + return err + } + p, err := eval(context.Background(), content) + if err != nil { + return err + } + + // XXX: It's a bit cumbersome to serialize and deserialize + // it into our own structure. + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + if err := enc.Encode(p); err != nil { + return err + } + e.publisher = new(csaf.CSAFPublisher) + if err := json.Unmarshal(buf.Bytes(), e.publisher); err != nil { + return err + } + return e.publisher.Validate() +} diff --git a/cmd/csaf_provider/indices.go b/cmd/csaf_provider/indices.go new file mode 100644 index 0000000..46d35f4 --- /dev/null +++ b/cmd/csaf_provider/indices.go @@ -0,0 +1,158 @@ +package main + +import ( + "bufio" + "encoding/csv" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" +) + +func updateIndex(dir, fname string) error { + + index := filepath.Join(dir, "index.txt") + + lines, err := func() ([]string, error) { + f, err := os.Open(index) + if err != nil { + if os.IsNotExist(err) { + return []string{fname}, nil + } else { + return nil, err + } + } + defer f.Close() + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + // stop scanning when we found it. + if line := scanner.Text(); line != fname { + lines = append(lines, line) + } else { + return nil, nil + } + } + return append(lines, fname), nil + }() + if err != nil { + return err + } + if len(lines) == 0 { + return nil + } + // Create new to break hard link. + f, err := os.Create(index) + if err != nil { + return err + } + sort.Strings(lines) + out := bufio.NewWriter(f) + for _, line := range lines { + fmt.Fprintln(out, line) + } + if err := out.Flush(); err != nil { + f.Close() + return err + } + return f.Close() +} + +func updateChanges(dir, fname string, releaseDate time.Time) error { + + type change struct { + time time.Time + path string + } + + changes := filepath.Join(dir, "changes.csv") + + chs, err := func() ([]change, error) { + f, err := os.Open(changes) + if err != nil { + if os.IsNotExist(err) { + return []change{{releaseDate, fname}}, nil + } + return nil, err + } + defer f.Close() + var chs []change + r := csv.NewReader(f) + r.FieldsPerRecord = 2 + r.ReuseRecord = true + replaced := false + for { + record, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + // Check if new is already in. + if record[1] == fname { + // Identical -> no change at all. + if record[0] == releaseDate.Format(dateFormat) { + return nil, nil + } + // replace old entry + replaced = true + chs = append(chs, change{releaseDate, fname}) + continue + } + t, err := time.Parse(dateFormat, record[0]) + if err != nil { + return nil, err + } + chs = append(chs, change{t, record[1]}) + } + if !replaced { + chs = append(chs, change{releaseDate, fname}) + } + return chs, nil + }() + + if err != nil { + return err + } + if len(chs) == 0 { + return nil + } + // Sort descending + sort.Slice(chs, func(i, j int) bool { + return chs[j].time.Before(chs[i].time) + }) + // Create new to break hard link. + o, err := os.Create(changes) + if err != nil { + return err + } + c := csv.NewWriter(o) + record := make([]string, 2) + for _, ch := range chs { + record[0] = ch.time.Format(dateFormat) + record[1] = ch.path + if err := c.Write(record); err != nil { + o.Close() + return err + } + } + c.Flush() + err1 := c.Error() + err2 := o.Close() + if err1 != nil { + return err1 + } + return err2 +} + +func updateIndices(dir, fname string, releaseDate time.Time) error { + + if err := updateIndex(dir, fname); err != nil { + return err + } + + return updateChanges(dir, fname, releaseDate) +} diff --git a/cmd/csaf_provider/main.go b/cmd/csaf_provider/main.go new file mode 100644 index 0000000..d4b527a --- /dev/null +++ b/cmd/csaf_provider/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "log" + "net/http/cgi" +) + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatalf("error: %v\n", err) + } + + c, err := newController(cfg) + if err != nil { + log.Fatalf("error: %v\n", err) + } + pim := newPathInfoMux() + c.bind(pim) + + if err := cgi.Serve(pim); err != nil { + log.Fatalf("error: %v\n", err) + } +} diff --git a/cmd/csaf_provider/mux.go b/cmd/csaf_provider/mux.go new file mode 100644 index 0000000..12b17ed --- /dev/null +++ b/cmd/csaf_provider/mux.go @@ -0,0 +1,38 @@ +package main + +import ( + "net/http" + "os" + "strings" +) + +type pathInfoMux struct { + routes map[string]http.Handler +} + +func newPathInfoMux() *pathInfoMux { + return &pathInfoMux{routes: map[string]http.Handler{}} +} + +func (pim *pathInfoMux) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + pi := os.Getenv("PATH_INFO") + if h, ok := pim.routes[pi]; ok { + h.ServeHTTP(rw, req) + return + } + for k, v := range pim.routes { + if strings.HasPrefix(k, pi) { + v.ServeHTTP(rw, req) + return + } + } + http.NotFound(rw, req) +} + +func (pim *pathInfoMux) handle(pattern string, handler http.Handler) { + pim.routes[pattern] = handler +} + +func (pim *pathInfoMux) handleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) { + pim.handle(pattern, http.HandlerFunc(handler)) +} diff --git a/cmd/csaf_provider/tmpl/create.html b/cmd/csaf_provider/tmpl/create.html new file mode 100644 index 0000000..37bfadc --- /dev/null +++ b/cmd/csaf_provider/tmpl/create.html @@ -0,0 +1,16 @@ + + +
+ + +| CSAF file: | {{ .Name }} |
| Release date: | {{ .ReleaseDate }} |