Supports only /etc/passwd at the moment.
--- /dev/null
+/filetype_string.go
+/nss/libnss_cash.so.2
+/nsscash
--- /dev/null
+all:
+ go generate ./...
+ go vet ./...
+ go build
+ go test ./...
+
+clean:
+ rm -f nsscash filetype_string.go
+
+.PHONY: all clean
--- /dev/null
+= 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
--- /dev/null
+// 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
+}
--- /dev/null
+// 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
+}
--- /dev/null
+// 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)
+}
--- /dev/null
+// 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)
+ }
+}
--- /dev/null
+# 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
--- /dev/null
+/*
+ * 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
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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
--- /dev/null
+/*
+ * 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);
+}
--- /dev/null
+/*
+ * 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);
+}
--- /dev/null
+/*
+ * 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
--- /dev/null
+// 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
+}
--- /dev/null
+// 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)
+}