]> ruderich.org/simon Gitweb - nsscash/nsscash.git/commitdiff
First working version
authorSimon Ruderich <simon@ruderich.org>
Sat, 8 Jun 2019 10:24:27 +0000 (12:24 +0200)
committerSimon Ruderich <simon@ruderich.org>
Sat, 8 Jun 2019 10:24:27 +0000 (12:24 +0200)
Supports only /etc/passwd at the moment.

16 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
README [new file with mode: 0644]
config.go [new file with mode: 0644]
fetch.go [new file with mode: 0644]
file.go [new file with mode: 0644]
main.go [new file with mode: 0644]
nss/Makefile [new file with mode: 0644]
nss/cash.h [new file with mode: 0644]
nss/file.c [new file with mode: 0644]
nss/file.h [new file with mode: 0644]
nss/pw.c [new file with mode: 0644]
nss/search.c [new file with mode: 0644]
nss/search.h [new file with mode: 0644]
passwd.go [new file with mode: 0644]
state.go [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..d409a2e
--- /dev/null
@@ -0,0 +1,3 @@
+/filetype_string.go
+/nss/libnss_cash.so.2
+/nsscash
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..fe36167
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,10 @@
+all:
+       go generate ./...
+       go vet ./...
+       go build
+       go test ./...
+
+clean:
+       rm -f nsscash filetype_string.go
+
+.PHONY: all clean
diff --git a/README b/README
new file mode 100644 (file)
index 0000000..a919efc
--- /dev/null
+++ b/README
@@ -0,0 +1,49 @@
+= README
+
+Nsscash (a pun on cache) is a simple file-based cache for NSS similar to
+nsscache [1]. The goal is to distribute users/groups/etc. to multiple systems
+without having to rely on a (single) stable server. Traditional systems like
+LDAP or NIS require a stable server or users/groups cannot be resolved. By
+distributing the data to all systems, temporary outages of the server cause no
+issues on the clients. In addition the local storage is much faster than
+remote network access. To update the local caches polling via HTTP is
+performed, for example every minute, only downloading new data if anything has
+changed.
+
+Nsscash consists of two parts: `nsscash`, written in Go, which downloads files
+via HTTP or HTTPS, parses them, creates indices and writes the result to a
+local file. The second part is the NSS module (`libnss_cash.so.2`), written in
+C, which provides integration via `/etc/nsswitch.conf`. It's specifically
+designed to be very simple and uses the prepared data for lookups. To support
+quick lookups, in O(log n), the files utilize indices.
+
+nsscash is licensed under AGPL version 3 or later.
+
+[1] https://github.com/google/nsscache
+
+
+== AUTHORS
+
+Written by Simon Ruderich <simon@ruderich.org>.
+
+
+== LICENSE
+
+This program is licensed under AGPL version 3 or later.
+
+Copyright (C) 2019  Simon Ruderich
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+// vim: ft=asciidoc
diff --git a/config.go b/config.go
new file mode 100644 (file)
index 0000000..8e9a630
--- /dev/null
+++ b/config.go
@@ -0,0 +1,87 @@
+// Configuration file parsing and validation
+
+// Copyright (C) 2019  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "fmt"
+
+       "github.com/BurntSushi/toml"
+)
+
+type Config struct {
+       StatePath string
+       Files     []File `toml:"file"`
+}
+
+type File struct {
+       Type FileType
+       Url  string
+       Path string
+
+       body []byte // internally used by handleFiles()
+}
+
+//go:generate stringer -type=FileType
+type FileType int
+
+const (
+       FileTypePlain FileType = iota
+       FileTypePasswd
+)
+
+func (t *FileType) UnmarshalText(text []byte) error {
+       switch string(text) {
+       case "plain":
+               *t = FileTypePlain
+       case "passwd":
+               *t = FileTypePasswd
+       default:
+               return fmt.Errorf("invalid file type %q", text)
+       }
+       return nil
+}
+
+func LoadConfig(path string) (*Config, error) {
+       var cfg Config
+
+       md, err := toml.DecodeFile(path, &cfg)
+       if err != nil {
+               return nil, err
+       }
+       undecoded := md.Undecoded()
+       if len(undecoded) != 0 {
+               return nil, fmt.Errorf("invalid fields used: %q", undecoded)
+       }
+
+       if cfg.StatePath == "" {
+               return nil, fmt.Errorf("statepath must not be empty")
+       }
+
+       for i, f := range cfg.Files {
+               if f.Url == "" {
+                       return nil, fmt.Errorf(
+                               "file[%d].url must not be empty", i)
+               }
+               if f.Path == "" {
+                       return nil, fmt.Errorf(
+                               "file[%d].path must not be empty", i)
+               }
+       }
+
+       return &cfg, nil
+}
diff --git a/fetch.go b/fetch.go
new file mode 100644 (file)
index 0000000..9834f3e
--- /dev/null
+++ b/fetch.go
@@ -0,0 +1,60 @@
+// Download files via HTTP with support for If-Modified-Since
+
+// Copyright (C) 2019  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "io/ioutil"
+       "net/http"
+       "time"
+)
+
+// Global variable to permit reuse of connections (keep-alive)
+var client *http.Client
+
+func init() {
+       client = &http.Client{}
+}
+
+func fetchIfModified(url string, lastModified *time.Time) (int, []byte, error) {
+       req, err := http.NewRequest("GET", url, nil)
+       if err != nil {
+               return 0, nil, err
+       }
+       if !lastModified.IsZero() {
+               req.Header.Add("If-Modified-Since",
+                       lastModified.Format(http.TimeFormat))
+       }
+
+       resp, err := client.Do(req)
+       if err != nil {
+               return 0, nil, err
+       }
+       defer resp.Body.Close()
+
+       body, err := ioutil.ReadAll(resp.Body)
+       if err != nil {
+               return 0, nil, err
+       }
+
+       modified, err := http.ParseTime(resp.Header.Get("Last-Modified"))
+       if err == nil {
+               *lastModified = modified
+       }
+
+       return resp.StatusCode, body, nil
+}
diff --git a/file.go b/file.go
new file mode 100644 (file)
index 0000000..b48d075
--- /dev/null
+++ b/file.go
@@ -0,0 +1,152 @@
+// Download and write files atomically to the file system
+
+// Copyright (C) 2019  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "bytes"
+       "fmt"
+       "io/ioutil"
+       "log"
+       "net/http"
+       "os"
+       "path/filepath"
+       "syscall"
+
+       "github.com/pkg/errors"
+)
+
+func handleFiles(cfg *Config, state *State) error {
+       for i, f := range cfg.Files {
+               err := fetchFile(&cfg.Files[i], state)
+               if err != nil {
+                       return errors.Wrapf(err, "%q (%s)", f.Url, f.Type)
+               }
+       }
+
+       for i, f := range cfg.Files {
+               // No update required
+               if f.body == nil {
+                       continue
+               }
+
+               err := deployFile(&cfg.Files[i])
+               if err != nil {
+                       return errors.Wrapf(err, "%q (%s)", f.Url, f.Type)
+               }
+       }
+
+       return nil
+}
+
+func fetchFile(file *File, state *State) error {
+       t := state.LastModified[file.Url]
+       status, body, err := fetchIfModified(file.Url, &t)
+       if err != nil {
+               return err
+       }
+       if status == http.StatusNotModified {
+               log.Printf("%q -> %q: not modified", file.Url, file.Path)
+               return nil
+       }
+       if status != http.StatusOK {
+               return fmt.Errorf("status code %v", status)
+       }
+       state.LastModified[file.Url] = t
+
+       if file.Type == FileTypePlain {
+               if len(body) == 0 {
+                       return fmt.Errorf("refusing to use empty response")
+               }
+               file.body = body
+
+       } else if file.Type == FileTypePasswd {
+               pws, err := ParsePasswds(bytes.NewReader(body))
+               if err != nil {
+                       return err
+               }
+               // Safety check: having no users can be very dangerous, don't
+               // permit it
+               if len(pws) == 0 {
+                       return fmt.Errorf("refusing to use empty passwd file")
+               }
+
+               var x bytes.Buffer
+               err = SerializePasswds(&x, pws)
+               if err != nil {
+                       return err
+               }
+               file.body = x.Bytes()
+
+       } else {
+               return fmt.Errorf("unsupported file type %v", file.Type)
+       }
+       return nil
+}
+
+func deployFile(file *File) error {
+       log.Printf("%q -> %q: updating file", file.Url, file.Path)
+
+       // Safety check
+       if len(file.body) == 0 {
+               return fmt.Errorf("refusing to write empty file")
+       }
+
+       // Write the file in an atomic fashion by creating a temporary file
+       // and renaming it over the target file
+
+       dir := filepath.Dir(file.Path)
+       name := filepath.Base(file.Path)
+
+       f, err := ioutil.TempFile(dir, "tmp-"+name+"-")
+       if err != nil {
+               return err
+       }
+       defer os.Remove(f.Name())
+       defer f.Close()
+
+       // Apply permissions/user/group from the target file
+       stat, err := os.Stat(file.Path)
+       if err != nil {
+               // We do not create the path if it doesn't exist, because we
+               // do not know the proper permissions
+               return errors.Wrapf(err, "file.path %q must exist", file.Path)
+       }
+       err = f.Chmod(stat.Mode())
+       if err != nil {
+               return err
+       }
+       // TODO: support more systems
+       sys, ok := stat.Sys().(*syscall.Stat_t)
+       if !ok {
+               return fmt.Errorf("unsupported FileInfo.Sys()")
+       }
+       err = f.Chown(int(sys.Uid), int(sys.Gid))
+       if err != nil {
+               return err
+       }
+
+       _, err = f.Write(file.body)
+       if err != nil {
+               return err
+       }
+       err = f.Sync()
+       if err != nil {
+               return err
+       }
+       return os.Rename(f.Name(), file.Path)
+}
diff --git a/main.go b/main.go
new file mode 100644 (file)
index 0000000..5026387
--- /dev/null
+++ b/main.go
@@ -0,0 +1,49 @@
+// Main file for nsscash
+
+// Copyright (C) 2019  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "log"
+       "os"
+)
+
+func main() {
+       if len(os.Args) != 2 {
+               log.SetFlags(0)
+               log.Fatalf("usage: %s <path/to/config>\n", os.Args[0])
+       }
+
+       cfg, err := LoadConfig(os.Args[1])
+       if err != nil {
+               log.Fatal(err)
+       }
+       state, err := LoadState(cfg.StatePath)
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       err = handleFiles(cfg, state)
+       if err != nil {
+               log.Fatal(err)
+       }
+
+       err = WriteStateIfChanged(cfg.StatePath, state)
+       if err != nil {
+               log.Fatal(err)
+       }
+}
diff --git a/nss/Makefile b/nss/Makefile
new file mode 100644 (file)
index 0000000..4afd529
--- /dev/null
@@ -0,0 +1,24 @@
+# Compiler flags
+LDLIBS  = -pthread
+CFLAGS  = -fPIC -Wall -Wextra -Wconversion
+LDFLAGS = -shared
+# DEB_BUILD_MAINT_OPTIONS='hardening=+all qa=+bug' dpkg-buildflags --export=make
+CFLAGS   += -g -O2 -Werror=array-bounds -Werror=clobbered -Werror=volatile-register-var -Werror=implicit-function-declaration -fstack-protector-strong -Wformat -Werror=format-security
+CPPFLAGS += -Wdate-time -D_FORTIFY_SOURCE=2
+LDFLAGS  += -Wl,-z,relro -Wl,-z,now
+
+# During development
+#CFLAGS  += -fsanitize=address -fno-omit-frame-pointer -fsanitize=undefined
+#LDFLAGS += -fsanitize=address -fsanitize=undefined
+
+all: libnss_cash.so.2
+
+clean:
+       rm -f libnss_cash.so.2
+
+libnss_cash.so.2: $(wildcard *.c) $(wildcard *.h)
+       $(CC) -o $@ -Wl,-soname,$@ $(CFLAGS) $(CPPFLAGS) $(LDFLAGS) \
+               file.c pw.c search.c \
+               $(LDLIBS)
+
+.PHONY: all clean
diff --git a/nss/cash.h b/nss/cash.h
new file mode 100644 (file)
index 0000000..b6d92a6
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * General header of nsscash
+ *
+ * Copyright (C) 2019  Simon Ruderich
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef CASH_H
+#define CASH_H
+
+#include <stdint.h>
+
+
+// Global constants
+
+#define MAGIC "NSS-CASH"
+
+// Defined in Makefile
+#ifndef NSSCASH_PASSWD_FILE
+# define NSSCASH_PASSWD_FILE "/etc/passwd.nsscash"
+#endif
+
+
+// Global structs
+
+struct header {
+    char magic[8]; // magic string
+    uint64_t version; // also doubles as byte-order check
+
+    uint64_t count;
+
+    // All offsets are relative to data
+    uint64_t off_orig_index;
+    uint64_t off_id_index;
+    uint64_t off_name_index;
+    uint64_t off_data;
+
+    char data[];
+} __attribute__((packed));
+
+#endif
diff --git a/nss/file.c b/nss/file.c
new file mode 100644 (file)
index 0000000..30ea4f9
--- /dev/null
@@ -0,0 +1,88 @@
+/*
+ * Load and unload nsscash files
+ *
+ * Copyright (C) 2019  Simon Ruderich
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "file.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/mman.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+
+bool map_file(const char *path, struct file *f) {
+    // Fully initialize the struct for unmap_file() and other users
+    f->fd = -1;
+    f->size = 0;
+    f->next_index = 0;
+    f->header = NULL;
+
+    f->fd = open(path, O_RDONLY | O_CLOEXEC);
+    if (f->fd < 0) {
+        goto fail;
+    }
+    struct stat s;
+    if (fstat(f->fd, &s)) {
+        goto fail;
+    }
+    f->size = (size_t)s.st_size;
+
+    void *x = mmap(NULL, f->size, PROT_READ, MAP_PRIVATE, f->fd, 0);
+    if (x == MAP_FAILED) {
+        goto fail;
+    }
+
+    const struct header *h = x;
+    f->header = h;
+
+    // Check MAGIC
+    if (memcmp(h->magic, MAGIC, sizeof(h->magic))) {
+        errno = EINVAL;
+        goto fail;
+    }
+    // Only version 1 is supported at the moment; this will also prevent
+    // running on big-endian systems which is currently not possible
+    if (h->version != 1) {
+        errno = EINVAL;
+        goto fail;
+    }
+
+    return true;
+
+fail: {
+        int save_errno = errno;
+        unmap_file(f);
+        errno = save_errno;
+        return false;
+    }
+}
+
+void unmap_file(struct file *f) {
+    if (f->header != NULL) {
+        munmap((void *)f->header, f->size);
+        f->header = NULL;
+    }
+    if (f->fd != -1) {
+        close(f->fd);
+        f->fd = -1;
+    }
+}
diff --git a/nss/file.h b/nss/file.h
new file mode 100644 (file)
index 0000000..7aac37c
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * Load and unload nsscash files (header)
+ *
+ * Copyright (C) 2019  Simon Ruderich
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef FILE_H
+#define FILE_H
+
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdlib.h>
+
+#include "cash.h"
+
+
+struct file {
+    int fd;
+    size_t size;
+    uint64_t next_index; // used by getpwent (pw.c)
+
+    const struct header *header;
+};
+
+bool map_file(const char *path, struct file *f) __attribute__((visibility("hidden")));
+void unmap_file(struct file *f) __attribute__((visibility("hidden")));
+
+#endif
diff --git a/nss/pw.c b/nss/pw.c
new file mode 100644 (file)
index 0000000..813b4f5
--- /dev/null
+++ b/nss/pw.c
@@ -0,0 +1,181 @@
+/*
+ * Handle passwd entries via struct passwd
+ *
+ * Copyright (C) 2019  Simon Ruderich
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <errno.h>
+#include <nss.h>
+#include <pwd.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <pthread.h>
+
+#include "cash.h"
+#include "file.h"
+#include "search.h"
+
+
+struct passwd_entry {
+    uint64_t uid;
+    uint64_t gid;
+
+    //       off_name = 0
+    uint16_t off_passwd;
+    uint16_t off_gecos;
+    uint16_t off_dir;
+    uint16_t off_shell;
+
+    uint16_t data_size;
+    /*
+     * Data contains all strings (name, passwd, gecos, dir, shell)
+     * concatenated, with their trailing NUL. The off_* variables point to
+     * beginning of each string.
+     */
+    char data[];
+} __attribute__((packed));
+
+static bool entry_to_passwd(const struct passwd_entry *e, struct passwd *p, char *tmp, size_t space) {
+    if (space < e->data_size) {
+        return false;
+    }
+
+    memcpy(tmp, e->data, e->data_size);
+    p->pw_uid = (uid_t)e->uid;
+    p->pw_gid = (gid_t)e->gid;
+    p->pw_name = tmp + 0;
+    p->pw_passwd = tmp + e->off_passwd;
+    p->pw_gecos = tmp + e->off_gecos;
+    p->pw_dir = tmp + e->off_dir;
+    p->pw_shell = tmp + e->off_shell;
+
+    return true;
+}
+
+
+static struct file static_file = {
+    .fd = -1,
+};
+static pthread_mutex_t static_file_lock = PTHREAD_MUTEX_INITIALIZER;
+
+enum nss_status _nss_cash_setpwent(int x) {
+    (void)x;
+
+    pthread_mutex_lock(&static_file_lock);
+    // Unmap is necessary to detect changes when the file was replaced on
+    // disk
+    unmap_file(&static_file);
+    // getpwent_r will open the file if necessary when called
+    pthread_mutex_unlock(&static_file_lock);
+
+    return NSS_STATUS_SUCCESS;
+}
+
+enum nss_status _nss_cash_endpwent(void) {
+    pthread_mutex_lock(&static_file_lock);
+    unmap_file(&static_file);
+    pthread_mutex_unlock(&static_file_lock);
+
+    return NSS_STATUS_SUCCESS;
+}
+
+static enum nss_status internal_getpwent_r(struct passwd *result, char *buffer, size_t buflen) {
+    // First call to getpwent_r, load file from disk
+    if (static_file.header == NULL) {
+        if (!map_file(NSSCASH_PASSWD_FILE, &static_file)) {
+            return NSS_STATUS_UNAVAIL;
+        }
+    }
+
+    const struct header *h = static_file.header;
+    // End of "file", stop
+    if (static_file.next_index >= h->count) {
+        errno = ENOENT;
+        return NSS_STATUS_NOTFOUND;
+    }
+
+    uint64_t *off_orig = (uint64_t *)(h->data + h->off_orig_index);
+    const char *e = h->data + h->off_data + off_orig[static_file.next_index];
+    if (!entry_to_passwd((struct passwd_entry *)e, result, buffer, buflen)) {
+        errno = ERANGE;
+        return NSS_STATUS_TRYAGAIN;
+    }
+    static_file.next_index++;
+
+    return NSS_STATUS_SUCCESS;
+}
+enum nss_status _nss_cash_getpwent_r(struct passwd *result, char *buffer, size_t buflen, int *errnop) {
+    pthread_mutex_lock(&static_file_lock);
+    enum nss_status s = internal_getpwent_r(result, buffer, buflen);
+    pthread_mutex_unlock(&static_file_lock);
+    if (s != NSS_STATUS_SUCCESS) {
+        *errnop = errno;
+    }
+    return s;
+}
+
+
+static enum nss_status internal_getpw(struct search_key *key, struct passwd *result, char *buffer, size_t buflen, int *errnop) {
+    struct file f;
+    if (!map_file(NSSCASH_PASSWD_FILE, &f)) {
+        *errnop = errno;
+        return NSS_STATUS_UNAVAIL;
+    }
+    const struct header *h = f.header;
+
+    key->data = h->data + h->off_data;
+    uint64_t off_index = (key->id != NULL)
+                       ? h->off_id_index
+                       : h->off_name_index;
+    uint64_t *off = search(key, h->data + off_index, h->count);
+    if (off == NULL) {
+        unmap_file(&f);
+        errno = ENOENT;
+        *errnop = errno;
+        return NSS_STATUS_NOTFOUND;
+    }
+
+    const char *e = h->data + h->off_data + *off;
+    if (!entry_to_passwd((struct passwd_entry *)e, result, buffer, buflen)) {
+        unmap_file(&f);
+        errno = ERANGE;
+        *errnop = errno;
+        return NSS_STATUS_TRYAGAIN;
+    }
+
+    unmap_file(&f);
+    return NSS_STATUS_SUCCESS;
+}
+
+enum nss_status _nss_cash_getpwuid_r(uid_t uid, struct passwd *result, char *buffer, size_t buflen, int *errnop) {
+    uint64_t id = (uint64_t)uid;
+    struct search_key key = {
+        .id = &id,
+        .offset = offsetof(struct passwd_entry, uid),
+    };
+    return internal_getpw(&key, result, buffer, buflen, errnop);
+}
+
+enum nss_status _nss_cash_getpwnam_r(const char *name, struct passwd *result, char *buffer, size_t buflen, int *errnop) {
+    struct search_key key = {
+        .name = name,
+        .offset = sizeof(struct passwd_entry), // name is first value in data[]
+    };
+    return internal_getpw(&key, result, buffer, buflen, errnop);
+}
diff --git a/nss/search.c b/nss/search.c
new file mode 100644 (file)
index 0000000..d4f7106
--- /dev/null
@@ -0,0 +1,55 @@
+/*
+ * Search entries in nsscash files by using indices and binary search
+ *
+ * Copyright (C) 2019  Simon Ruderich
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "search.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+
+static int bsearch_callback(const void *x, const void *y) {
+    const struct search_key *key = x;
+
+    uint64_t offset = *(const uint64_t *)y;
+    const void *member = (const char *)key->data + offset + key->offset;
+
+    // Lookup by name
+    if (key->name != NULL) {
+        const char *name = member;
+        return strcmp(key->name, name);
+
+    // Lookup by ID
+    } else if (key->id != NULL) {
+        const uint64_t *id = member;
+        if (*key->id < *id) {
+            return -1;
+        } else if (*key->id == *id) {
+            return 0;
+        } else {
+            return +1;
+        }
+
+    } else {
+        abort();
+    }
+}
+
+uint64_t *search(struct search_key *key, const void *index, uint64_t count) {
+    return bsearch(key, index, count, sizeof(uint64_t), bsearch_callback);
+}
diff --git a/nss/search.h b/nss/search.h
new file mode 100644 (file)
index 0000000..f85ef91
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * Search entries in nsscash files by using indices and binary search (header)
+ *
+ * Copyright (C) 2019  Simon Ruderich
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef SEARCH_H
+#define SEARCH_H
+
+#include <stdint.h>
+
+
+struct search_key {
+    const char *name;
+    const uint64_t *id;
+
+    uint64_t offset;
+    const void *data;
+};
+
+uint64_t *search(struct search_key *key, const void *index, uint64_t count) __attribute__((visibility("hidden")));
+
+#endif
diff --git a/passwd.go b/passwd.go
new file mode 100644 (file)
index 0000000..c07e9c7
--- /dev/null
+++ b/passwd.go
@@ -0,0 +1,244 @@
+// Parse /etc/passwd files and serialize them
+
+// Copyright (C) 2019  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "bufio"
+       "bytes"
+       "encoding/binary"
+       "fmt"
+       "io"
+       "sort"
+       "strconv"
+       "strings"
+
+       "github.com/pkg/errors"
+)
+
+// Version written in SerializePasswds()
+const PasswdVersion = 1
+
+type Passwd struct {
+       Name   string
+       Passwd string
+       Uid    uint64
+       Gid    uint64
+       Gecos  string
+       Dir    string
+       Shell  string
+}
+
+// ParsePasswds parses a file in the format of /etc/passwd and returns all
+// entries as Passwd structs.
+func ParsePasswds(r io.Reader) ([]Passwd, error) {
+       var res []Passwd
+
+       s := bufio.NewScanner(r)
+       for s.Scan() {
+               t := s.Text()
+
+               x := strings.Split(t, ":")
+               if len(x) != 7 {
+                       return nil, fmt.Errorf("invalid line %q", t)
+               }
+
+               uid, err := strconv.ParseUint(x[2], 10, 64)
+               if err != nil {
+                       return nil, errors.Wrapf(err, "invalid uid in line %q", t)
+               }
+               gid, err := strconv.ParseUint(x[3], 10, 64)
+               if err != nil {
+                       return nil, errors.Wrapf(err, "invalid gid in line %q", t)
+               }
+
+               res = append(res, Passwd{
+                       Name:   x[0],
+                       Passwd: x[1],
+                       Uid:    uid,
+                       Gid:    gid,
+                       Gecos:  x[4],
+                       Dir:    x[5],
+                       Shell:  x[6],
+               })
+       }
+       err := s.Err()
+       if err != nil {
+               return nil, err
+       }
+
+       return res, nil
+}
+
+func SerializePasswd(p Passwd) []byte {
+       // Concatenate all (NUL-terminated) strings and store the offsets
+       var data bytes.Buffer
+       data.Write([]byte(p.Name))
+       data.WriteByte(0)
+       offPasswd := uint16(data.Len())
+       data.Write([]byte(p.Passwd))
+       data.WriteByte(0)
+       offGecos := uint16(data.Len())
+       data.Write([]byte(p.Gecos))
+       data.WriteByte(0)
+       offDir := uint16(data.Len())
+       data.Write([]byte(p.Dir))
+       data.WriteByte(0)
+       offShell := uint16(data.Len())
+       data.Write([]byte(p.Shell))
+       data.WriteByte(0)
+       size := uint16(data.Len())
+
+       var res bytes.Buffer // serialized result
+       le := binary.LittleEndian
+
+       id := make([]byte, 8)
+       // uid
+       le.PutUint64(id, p.Uid)
+       res.Write(id)
+       // gid
+       le.PutUint64(id, p.Gid)
+       res.Write(id)
+
+       off := make([]byte, 2)
+       // off_passwd
+       le.PutUint16(off, offPasswd)
+       res.Write(off)
+       // off_gecos
+       le.PutUint16(off, offGecos)
+       res.Write(off)
+       // off_dir
+       le.PutUint16(off, offDir)
+       res.Write(off)
+       // off_shell
+       le.PutUint16(off, offShell)
+       res.Write(off)
+       // data_size
+       le.PutUint16(off, size)
+       res.Write(off)
+
+       res.Write(data.Bytes())
+       // We must pad each entry so that all uint64 at the beginning of the
+       // struct are 8 byte aligned
+       l := res.Len()
+       if l%8 != 0 {
+               for i := 0; i < 8-l%8; i++ {
+                       res.Write([]byte{'0'})
+               }
+       }
+
+       return res.Bytes()
+}
+
+func SerializePasswds(w io.Writer, pws []Passwd) error {
+       // Serialize passwords and store offsets
+       var data bytes.Buffer
+       offsets := make(map[Passwd]uint64)
+       for _, p := range pws {
+               // TODO: warn about duplicate entries
+               offsets[p] = uint64(data.Len())
+               data.Write(SerializePasswd(p))
+       }
+
+       // Copy to prevent sorting from modifying the argument
+       sorted := make([]Passwd, len(pws))
+       copy(sorted, pws)
+
+       le := binary.LittleEndian
+       tmp := make([]byte, 8)
+
+       // Create index "sorted" in input order, used when iterating over all
+       // passwd entries (getpwent_r); keeping the original order makes
+       // debugging easier
+       var indexOrig bytes.Buffer
+       for _, p := range pws {
+               le.PutUint64(tmp, offsets[p])
+               indexOrig.Write(tmp)
+       }
+
+       // Create index sorted after id
+       var indexId bytes.Buffer
+       sort.Slice(sorted, func(i, j int) bool {
+               return sorted[i].Uid < sorted[j].Uid
+       })
+       for _, p := range sorted {
+               le.PutUint64(tmp, offsets[p])
+               indexId.Write(tmp)
+       }
+
+       // Create index sorted after name
+       var indexName bytes.Buffer
+       sort.Slice(sorted, func(i, j int) bool {
+               return sorted[i].Name < sorted[j].Name
+       })
+       for _, p := range sorted {
+               le.PutUint64(tmp, offsets[p])
+               indexName.Write(tmp)
+       }
+
+       // Sanity check
+       if indexOrig.Len() != indexId.Len() ||
+               indexId.Len() != indexName.Len() {
+               return fmt.Errorf("indexes have inconsistent length")
+       }
+
+       // Write result
+
+       // magic
+       w.Write([]byte("NSS-CASH"))
+       // version
+       le.PutUint64(tmp, PasswdVersion)
+       w.Write(tmp)
+       // count
+       le.PutUint64(tmp, uint64(len(pws)))
+       w.Write(tmp)
+       // off_orig_index
+       offset := uint64(0)
+       le.PutUint64(tmp, offset)
+       w.Write(tmp)
+       // off_id_index
+       offset += uint64(indexOrig.Len())
+       le.PutUint64(tmp, offset)
+       w.Write(tmp)
+       // off_name_index
+       offset += uint64(indexId.Len())
+       le.PutUint64(tmp, offset)
+       w.Write(tmp)
+       // off_data
+       offset += uint64(indexName.Len())
+       le.PutUint64(tmp, offset)
+       w.Write(tmp)
+
+       _, err := indexOrig.WriteTo(w)
+       if err != nil {
+               return err
+       }
+       _, err = indexId.WriteTo(w)
+       if err != nil {
+               return err
+       }
+       _, err = indexName.WriteTo(w)
+       if err != nil {
+               return err
+       }
+       _, err = data.WriteTo(w)
+       if err != nil {
+               return err
+       }
+
+       return nil
+}
diff --git a/state.go b/state.go
new file mode 100644 (file)
index 0000000..b0dec1c
--- /dev/null
+++ b/state.go
@@ -0,0 +1,98 @@
+// Read and write the state file used to keep data over multiple runs
+
+// Copyright (C) 2019  Simon Ruderich
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+package main
+
+import (
+       "encoding/json"
+       "io/ioutil"
+       "os"
+       "path/filepath"
+       "reflect"
+       "time"
+)
+
+type State struct {
+       LastModified map[string]time.Time
+
+       // Copy of LastModified to write the state file only on changes
+       origLastModified map[string]time.Time
+}
+
+func LoadState(path string) (*State, error) {
+       var state State
+
+       x, err := ioutil.ReadFile(path)
+       if err != nil {
+               // It's fine if the state file does not exist yet, we'll
+               // create it later when writing the state
+               if !os.IsNotExist(err) {
+                       return nil, err
+               }
+       } else {
+               err := json.Unmarshal(x, &state)
+               if err != nil {
+                       return nil, err
+               }
+       }
+
+       if state.LastModified == nil {
+               state.LastModified = make(map[string]time.Time)
+       }
+
+       state.origLastModified = make(map[string]time.Time)
+       for k, v := range state.LastModified {
+               state.origLastModified[k] = v
+       }
+
+       return &state, nil
+}
+
+func WriteStateIfChanged(path string, state *State) error {
+       // State hasn't changed, nothing to do
+       if reflect.DeepEqual(state.LastModified, state.origLastModified) {
+               return nil
+       }
+
+       x, err := json.Marshal(state)
+       if err != nil {
+               return err
+       }
+
+       // Write the file in an atomic fashion by creating a temporary file
+       // and renaming it over the target file
+
+       dir := filepath.Dir(path)
+       name := filepath.Base(path)
+
+       f, err := ioutil.TempFile(dir, "tmp-"+name+"-")
+       if err != nil {
+               return err
+       }
+       defer os.Remove(f.Name())
+       defer f.Close()
+
+       _, err = f.Write(x)
+       if err != nil {
+               return err
+       }
+       err = f.Sync()
+       if err != nil {
+               return err
+       }
+       return os.Rename(f.Name(), path)
+}