From 92afde4e875a96e1ab865e29b9f0d11b08d7db1c Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sat, 8 Jun 2019 12:24:27 +0200 Subject: [PATCH] First working version Supports only /etc/passwd at the moment. --- .gitignore | 3 + Makefile | 10 +++ README | 49 +++++++++++ config.go | 87 ++++++++++++++++++ fetch.go | 60 +++++++++++++ file.go | 152 ++++++++++++++++++++++++++++++++ main.go | 49 +++++++++++ nss/Makefile | 24 +++++ nss/cash.h | 53 +++++++++++ nss/file.c | 88 +++++++++++++++++++ nss/file.h | 41 +++++++++ nss/pw.c | 181 ++++++++++++++++++++++++++++++++++++++ nss/search.c | 55 ++++++++++++ nss/search.h | 36 ++++++++ passwd.go | 244 +++++++++++++++++++++++++++++++++++++++++++++++++++ state.go | 98 +++++++++++++++++++++ 16 files changed, 1230 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README create mode 100644 config.go create mode 100644 fetch.go create mode 100644 file.go create mode 100644 main.go create mode 100644 nss/Makefile create mode 100644 nss/cash.h create mode 100644 nss/file.c create mode 100644 nss/file.h create mode 100644 nss/pw.c create mode 100644 nss/search.c create mode 100644 nss/search.h create mode 100644 passwd.go create mode 100644 state.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d409a2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/filetype_string.go +/nss/libnss_cash.so.2 +/nsscash diff --git a/Makefile b/Makefile new file mode 100644 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 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 . + + +== 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 . + +// vim: ft=asciidoc diff --git a/config.go b/config.go new file mode 100644 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 . + +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 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 . + +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 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 . + +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 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 . + +package main + +import ( + "log" + "os" +) + +func main() { + if len(os.Args) != 2 { + log.SetFlags(0) + log.Fatalf("usage: %s \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 index 0000000..4afd529 --- /dev/null +++ b/nss/Makefile @@ -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 index 0000000..b6d92a6 --- /dev/null +++ b/nss/cash.h @@ -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 . + */ + +#ifndef CASH_H +#define CASH_H + +#include + + +// 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 index 0000000..30ea4f9 --- /dev/null +++ b/nss/file.c @@ -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 . + */ + +#include "file.h" + +#include +#include +#include +#include +#include +#include +#include +#include + + +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 index 0000000..7aac37c --- /dev/null +++ b/nss/file.h @@ -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 . + */ + +#ifndef FILE_H +#define FILE_H + +#include +#include +#include + +#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 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 . + */ + +#include +#include +#include +#include +#include +#include +#include + +#include + +#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 index 0000000..d4f7106 --- /dev/null +++ b/nss/search.c @@ -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 . + */ + +#include "search.h" + +#include +#include + + +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 index 0000000..f85ef91 --- /dev/null +++ b/nss/search.h @@ -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 . + */ + +#ifndef SEARCH_H +#define SEARCH_H + +#include + + +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 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 . + +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 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 . + +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) +} -- 2.43.2