From: Simon Ruderich Date: Sat, 8 Jun 2019 10:24:27 +0000 (+0200) Subject: First working version X-Git-Tag: 0.1~97 X-Git-Url: https://ruderich.org/simon/gitweb/?p=nsscash%2Fnsscash.git;a=commitdiff_plain;h=92afde4e875a96e1ab865e29b9f0d11b08d7db1c First working version Supports only /etc/passwd at the moment. --- 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) +}