1 // Copyright (C) 2019-2020 Simon Ruderich
3 // This program is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU Affero General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU Affero General Public License for more details.
13 // You should have received a copy of the GNU Affero General Public License
14 // along with this program. If not, see <https://www.gnu.org/licenses/>.
37 configPath = "testdata/config.toml"
38 statePath = "testdata/var/state.json"
39 passwdPath = "testdata/passwd.nsscash"
40 plainPath = "testdata/plain"
41 groupPath = "testdata/group.nsscash"
42 tlsCAPath = "testdata/ca.crt"
43 tlsCertPath = "testdata/server.crt"
44 tlsKeyPath = "testdata/server.key"
45 tlsCA2Path = "testdata/ca2.crt"
51 handler *func(http.ResponseWriter, *http.Request)
54 // mustNotExist verifies that all given paths don't exist in the file system.
55 func mustNotExist(t *testing.T, paths ...string) {
56 for _, p := range paths {
59 if !os.IsNotExist(err) {
60 t.Errorf("path %q: unexpected error: %v",
64 t.Errorf("path %q exists", p)
70 func hashAsHex(x []byte) string {
73 return hex.EncodeToString(h.Sum(nil))
76 // mustHaveHash checks if the given path content has the given SHA-1 string
78 func mustHaveHash(t *testing.T, path string, hash string) {
79 x, err := ioutil.ReadFile(path)
86 t.Errorf("%q has unexpected hash %q", path, y)
90 // mustBeErrorWithSubstring checks if the given error, represented as string,
91 // contains the given substring. This is somewhat ugly but the simplest way to
92 // check for proper errors.
93 func mustBeErrorWithSubstring(t *testing.T, err error, substring string) {
95 t.Errorf("err is nil")
96 } else if !strings.Contains(err.Error(), substring) {
97 t.Errorf("err %q does not contain string %q", err, substring)
101 func mustWriteConfig(t *testing.T, config string) {
102 err := ioutil.WriteFile(configPath, []byte(config), 0644)
108 func mustWritePasswdConfig(t *testing.T, url string) {
109 mustWriteConfig(t, fmt.Sprintf(`
117 `, statePath, url, passwdPath, tlsCAPath))
120 func mustWriteGroupConfig(t *testing.T, url string) {
121 mustWriteConfig(t, fmt.Sprintf(`
129 `, statePath, url, groupPath, tlsCAPath))
132 // mustCreate creates a file, truncating it if it exists. It then changes the
133 // modification to be in the past.
134 func mustCreate(t *testing.T, path string) {
135 f, err := os.Create(path)
144 // Change modification time to the past to detect updates to the file
148 // mustMakeOld change the modification time of all paths to be in the past.
149 func mustMakeOld(t *testing.T, paths ...string) {
150 old := time.Now().Add(-2 * time.Hour)
151 for _, p := range paths {
152 err := os.Chtimes(p, old, old)
159 // mustBeOld verifies that all paths have a modification time in the past, as
160 // set by mustMakeOld.
161 func mustBeOld(t *testing.T, paths ...string) {
162 for _, p := range paths {
169 if time.Since(mtime) < time.Hour {
170 t.Errorf("%q was recently modified", p)
175 // mustBeNew verifies that all paths have a modification time in the present.
176 func mustBeNew(t *testing.T, paths ...string) {
177 for _, p := range paths {
184 if time.Since(mtime) > time.Hour {
185 t.Errorf("%q was not recently modified", p)
190 func TestMainFetch(t *testing.T) {
191 // Suppress log messages
192 log.SetOutput(ioutil.Discard)
193 defer log.SetOutput(os.Stderr)
195 tests := []func(args){
196 // Perform most tests with passwd for simplicity
197 fetchPasswdCacheFileDoesNotExist,
199 fetchPasswdUnexpected304,
204 // Tests for plain and group
213 fetchStateCannotRead,
215 fetchStateCannotWrite,
217 fetchSecondFetchFails,
219 // TODO: fetchCannotDeployMultiple,
224 for _, f := range tests {
225 runMainTest(t, f, nil)
230 tests = append(tests, fetchInvalidCA)
232 cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath)
237 Certificates: []tls.Certificate{cert},
240 for _, f := range tests {
241 runMainTest(t, f, tls)
245 func runMainTest(t *testing.T, f func(args), tls *tls.Config) {
254 // NOTE: This is not guaranteed to work according to reflect's
255 // documentation but seems to work reliable for normal functions.
256 fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
258 name = name[strings.LastIndex(name, ".")+1:]
263 t.Run(name, func(t *testing.T) {
264 // Preparation & cleanup
265 for _, p := range cleanup {
267 if err != nil && !os.IsNotExist(err) {
270 // Remove the file at the end of this test run, if it
274 dir := filepath.Dir(p)
275 err = os.MkdirAll(dir, 0755)
279 defer os.Remove(dir) // remove empty directories
282 var handler func(http.ResponseWriter, *http.Request)
283 ts := httptest.NewUnstartedServer(http.HandlerFunc(
284 func(w http.ResponseWriter, r *http.Request) {
303 func fetchPasswdCacheFileDoesNotExist(a args) {
305 mustWritePasswdConfig(t, a.url)
307 err := mainFetch(configPath)
308 mustBeErrorWithSubstring(t, err,
309 "file.path \""+passwdPath+"\" must exist")
311 mustNotExist(t, statePath, passwdPath, plainPath, groupPath)
314 func fetchPasswd404(a args) {
316 mustWritePasswdConfig(t, a.url)
317 mustCreate(t, passwdPath)
319 *a.handler = func(w http.ResponseWriter, r *http.Request) {
321 w.WriteHeader(http.StatusNotFound)
324 err := mainFetch(configPath)
325 mustBeErrorWithSubstring(t, err,
328 mustNotExist(t, statePath, plainPath, groupPath)
329 mustBeOld(t, passwdPath)
332 func fetchPasswdUnexpected304(a args) {
334 mustWritePasswdConfig(t, a.url)
335 mustCreate(t, passwdPath)
337 *a.handler = func(w http.ResponseWriter, r *http.Request) {
339 w.WriteHeader(http.StatusNotModified)
342 err := mainFetch(configPath)
343 mustBeErrorWithSubstring(t, err,
344 "status code 304 but did not send If-Modified-Since")
346 mustNotExist(t, statePath, plainPath, groupPath)
347 mustBeOld(t, passwdPath)
350 func fetchPasswdEmpty(a args) {
352 mustWritePasswdConfig(t, a.url)
353 mustCreate(t, passwdPath)
355 *a.handler = func(w http.ResponseWriter, r *http.Request) {
359 err := mainFetch(configPath)
360 mustBeErrorWithSubstring(t, err,
361 "refusing to use empty passwd file")
363 mustNotExist(t, statePath, plainPath, groupPath)
364 mustBeOld(t, passwdPath)
367 func fetchPasswdInvalid(a args) {
369 mustWritePasswdConfig(t, a.url)
370 mustCreate(t, passwdPath)
372 *a.handler = func(w http.ResponseWriter, r *http.Request) {
373 if r.URL.Path != "/passwd" {
377 fmt.Fprintln(w, "root:x:invalid:0:root:/root:/bin/bash")
380 err := mainFetch(configPath)
381 mustBeErrorWithSubstring(t, err,
382 "invalid uid in line")
384 mustNotExist(t, statePath, plainPath, groupPath)
385 mustBeOld(t, passwdPath)
388 func fetchPasswdLimits(a args) {
390 mustWritePasswdConfig(t, a.url)
391 mustCreate(t, passwdPath)
393 *a.handler = func(w http.ResponseWriter, r *http.Request) {
394 if r.URL.Path != "/passwd" {
398 fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash")
399 for i := 0; i < 65536; i++ {
405 err := mainFetch(configPath)
406 mustBeErrorWithSubstring(t, err,
407 "passwd too large to serialize")
409 mustNotExist(t, statePath, plainPath, groupPath)
410 mustBeOld(t, passwdPath)
413 func fetchPasswd(a args) {
415 mustWritePasswdConfig(t, a.url)
416 mustCreate(t, passwdPath)
417 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
419 t.Log("First fetch, write files")
421 *a.handler = func(w http.ResponseWriter, r *http.Request) {
422 if r.URL.Path != "/passwd" {
426 // No "Last-Modified" header
427 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
428 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
431 err := mainFetch(configPath)
436 mustNotExist(t, plainPath, groupPath)
437 mustBeNew(t, passwdPath, statePath)
438 // The actual content of passwdPath is verified by the NSS tests
439 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
441 t.Log("Fetch again, no support for Last-Modified")
443 mustMakeOld(t, passwdPath, statePath)
445 err = mainFetch(configPath)
450 mustNotExist(t, plainPath, groupPath)
451 mustBeNew(t, passwdPath, statePath)
452 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
454 t.Log("Fetch again, support for Last-Modified, but not retrieved yet")
456 mustMakeOld(t, passwdPath, statePath)
458 lastChange := time.Now()
460 *a.handler = func(w http.ResponseWriter, r *http.Request) {
461 if r.URL.Path != "/passwd" {
465 modified := r.Header.Get("If-Modified-Since")
467 x, err := http.ParseTime(modified)
469 t.Fatalf("invalid If-Modified-Since %v",
472 if !x.Before(lastChange.Truncate(time.Second)) {
473 w.WriteHeader(http.StatusNotModified)
478 w.Header().Add("Last-Modified",
479 lastChange.UTC().Format(http.TimeFormat))
480 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
481 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
483 fmt.Fprintln(w, "bin:x:2:2:bin:/bin:/usr/sbin/nologin")
487 err = mainFetch(configPath)
492 mustNotExist(t, plainPath, groupPath)
493 mustBeNew(t, passwdPath, statePath)
494 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
496 t.Log("Fetch again, support for Last-Modified")
498 mustMakeOld(t, passwdPath, statePath)
500 err = mainFetch(configPath)
505 mustNotExist(t, plainPath, groupPath)
506 mustBeOld(t, passwdPath)
507 mustBeNew(t, statePath)
508 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
510 t.Log("Corrupt local passwd cache, fetched again")
512 os.Chmod(passwdPath, 0644) // make writable again
513 mustCreate(t, passwdPath)
514 mustMakeOld(t, passwdPath, statePath)
516 err = mainFetch(configPath)
521 mustNotExist(t, plainPath, groupPath)
522 mustBeNew(t, passwdPath, statePath)
523 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
525 t.Log("Fetch again with newer server response")
528 lastChange = time.Now().Add(time.Second)
530 mustMakeOld(t, passwdPath, statePath)
532 err = mainFetch(configPath)
537 mustNotExist(t, plainPath, groupPath)
538 mustBeNew(t, passwdPath, statePath)
539 mustHaveHash(t, passwdPath, "ca9c7477cb425667fc9ecbd79e8e1c2ad0e84423")
542 func fetchPlainEmpty(a args) {
544 mustWriteConfig(t, fmt.Sprintf(`
552 `, statePath, a.url, plainPath, tlsCAPath))
553 mustCreate(t, plainPath)
555 *a.handler = func(w http.ResponseWriter, r *http.Request) {
559 err := mainFetch(configPath)
560 mustBeErrorWithSubstring(t, err,
561 "refusing to use empty response")
563 mustNotExist(t, statePath, passwdPath, groupPath)
564 mustBeOld(t, plainPath)
567 func fetchPlain(a args) {
569 mustWriteConfig(t, fmt.Sprintf(`
577 `, statePath, a.url, plainPath, tlsCAPath))
578 mustCreate(t, plainPath)
579 mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
581 *a.handler = func(w http.ResponseWriter, r *http.Request) {
582 if r.URL.Path != "/plain" {
586 fmt.Fprintln(w, "some file")
589 err := mainFetch(configPath)
594 mustNotExist(t, passwdPath, groupPath)
595 mustBeNew(t, plainPath, statePath)
596 mustHaveHash(t, plainPath, "0e08b5e8c10abc3e455b75286ba4a1fbd56e18a5")
598 // Remaining functionality already tested in fetchPasswd()
601 func fetchGroupEmpty(a args) {
603 mustWriteGroupConfig(t, a.url)
604 mustCreate(t, groupPath)
606 *a.handler = func(w http.ResponseWriter, r *http.Request) {
610 err := mainFetch(configPath)
611 mustBeErrorWithSubstring(t, err,
612 "refusing to use empty group file")
614 mustNotExist(t, statePath, passwdPath, plainPath)
615 mustBeOld(t, groupPath)
618 func fetchGroupInvalid(a args) {
620 mustWriteGroupConfig(t, a.url)
621 mustCreate(t, groupPath)
623 *a.handler = func(w http.ResponseWriter, r *http.Request) {
624 if r.URL.Path != "/group" {
628 fmt.Fprintln(w, "root:x::")
631 err := mainFetch(configPath)
632 mustBeErrorWithSubstring(t, err,
633 "invalid gid in line")
635 mustNotExist(t, statePath, passwdPath, plainPath)
636 mustBeOld(t, groupPath)
639 func fetchGroupLimits(a args) {
641 mustWriteGroupConfig(t, a.url)
642 mustCreate(t, groupPath)
644 *a.handler = func(w http.ResponseWriter, r *http.Request) {
645 if r.URL.Path != "/group" {
649 fmt.Fprint(w, "root:x:0:")
650 for i := 0; i < 65536; i++ {
656 err := mainFetch(configPath)
657 mustBeErrorWithSubstring(t, err,
658 "group too large to serialize")
660 mustNotExist(t, statePath, passwdPath, plainPath)
661 mustBeOld(t, groupPath)
664 func fetchGroup(a args) {
666 mustWriteGroupConfig(t, a.url)
667 mustCreate(t, groupPath)
668 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
670 *a.handler = func(w http.ResponseWriter, r *http.Request) {
671 if r.URL.Path != "/group" {
675 fmt.Fprintln(w, "root:x:0:")
676 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
679 err := mainFetch(configPath)
684 mustNotExist(t, passwdPath, plainPath)
685 mustBeNew(t, groupPath, statePath)
686 // The actual content of groupPath is verified by the NSS tests
687 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
689 // Remaining functionality already tested in fetchPasswd()
692 func fetchNoConfig(a args) {
695 err := mainFetch(configPath)
696 mustBeErrorWithSubstring(t, err,
697 configPath+": no such file or directory")
699 mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
702 func fetchStateCannotRead(a args) {
704 mustWritePasswdConfig(t, a.url)
706 mustCreate(t, statePath)
707 err := os.Chmod(statePath, 0000)
712 err = mainFetch(configPath)
713 mustBeErrorWithSubstring(t, err,
714 statePath+": permission denied")
716 mustNotExist(t, passwdPath, plainPath, groupPath)
717 mustBeOld(t, statePath)
720 func fetchStateInvalid(a args) {
722 mustWriteGroupConfig(t, a.url)
723 mustCreate(t, statePath)
725 err := mainFetch(configPath)
726 mustBeErrorWithSubstring(t, err,
727 "unexpected end of JSON input")
729 mustNotExist(t, groupPath, passwdPath, plainPath)
730 mustBeOld(t, statePath)
733 func fetchStateCannotWrite(a args) {
735 mustWriteGroupConfig(t, a.url)
736 mustCreate(t, groupPath)
737 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
739 *a.handler = func(w http.ResponseWriter, r *http.Request) {
740 if r.URL.Path != "/group" {
744 fmt.Fprintln(w, "root:x:0:")
745 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
748 err := os.Chmod(filepath.Dir(statePath), 0500)
752 defer os.Chmod(filepath.Dir(statePath), 0755)
754 err = mainFetch(configPath)
755 mustBeErrorWithSubstring(t, err,
758 mustNotExist(t, statePath, passwdPath, plainPath)
759 mustBeNew(t, groupPath)
760 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
763 func fetchCannotDeploy(a args) {
765 mustWriteGroupConfig(t, a.url)
766 mustCreate(t, groupPath)
767 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
769 *a.handler = func(w http.ResponseWriter, r *http.Request) {
770 if r.URL.Path != "/group" {
774 fmt.Fprintln(w, "root:x:0:")
775 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
778 err := os.Chmod("testdata", 0500)
782 defer os.Chmod("testdata", 0755)
784 err = mainFetch(configPath)
785 mustBeErrorWithSubstring(t, err,
788 mustNotExist(t, statePath, passwdPath, plainPath)
789 mustBeOld(t, groupPath)
792 func fetchSecondFetchFails(a args) {
794 mustWriteConfig(t, fmt.Sprintf(`
808 `, statePath, a.url, passwdPath, groupPath, tlsCAPath))
809 mustCreate(t, passwdPath)
810 mustCreate(t, groupPath)
811 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
812 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
814 *a.handler = func(w http.ResponseWriter, r *http.Request) {
815 if r.URL.Path == "/passwd" {
816 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
818 if r.URL.Path == "/group" {
819 w.WriteHeader(http.StatusNotFound)
823 err := mainFetch(configPath)
824 mustBeErrorWithSubstring(t, err,
827 mustNotExist(t, statePath, plainPath)
828 // Even though passwd was successfully fetched, no files were modified
829 // because the second fetch failed
830 mustBeOld(t, passwdPath, groupPath)
833 func fetchBasicAuth(a args) {
835 mustWritePasswdConfig(t, a.url)
836 mustCreate(t, passwdPath)
837 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
839 validUser := "username"
840 validPass := "password"
842 *a.handler = func(w http.ResponseWriter, r *http.Request) {
843 if r.URL.Path != "/passwd" {
847 user, pass, ok := r.BasicAuth()
848 // NOTE: Do not use this in production because it permits
849 // attackers to determine the length of user/pass. Instead use
850 // hashes and subtle.ConstantTimeCompare().
851 if !ok || user != validUser || pass != validPass {
852 w.Header().Set("WWW-Authenticate", `Basic realm="Test"`)
853 w.WriteHeader(http.StatusUnauthorized)
857 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
858 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
861 t.Log("Missing authentication")
863 err := mainFetch(configPath)
864 mustBeErrorWithSubstring(t, err,
867 mustNotExist(t, statePath, groupPath, plainPath)
868 mustBeOld(t, passwdPath)
870 t.Log("Unsafe config permissions")
872 mustWriteConfig(t, fmt.Sprintf(`
882 `, statePath, a.url, passwdPath, tlsCAPath, validUser, validPass))
884 err = os.Chmod(configPath, 0644)
889 err = mainFetch(configPath)
890 mustBeErrorWithSubstring(t, err,
891 "file[0].username/passsword in use and unsafe permissions "+
892 "-rw-r--r-- on \""+configPath+"\"")
894 mustNotExist(t, statePath, groupPath, plainPath)
895 mustBeOld(t, passwdPath)
897 t.Log("Working authentication")
899 err = os.Chmod(configPath, 0600)
904 err = mainFetch(configPath)
909 mustNotExist(t, plainPath, groupPath)
910 mustBeNew(t, passwdPath, statePath)
911 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
914 func fetchInvalidCA(a args) {
919 mustWriteConfig(t, fmt.Sprintf(`
926 `, statePath, a.url, passwdPath))
927 mustCreate(t, passwdPath)
928 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
930 *a.handler = func(w http.ResponseWriter, r *http.Request) {
931 if r.URL.Path == "/passwd" {
932 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
936 err := mainFetch(configPath)
937 mustBeErrorWithSubstring(t, err,
938 "x509: certificate signed by unknown authority")
940 mustNotExist(t, statePath, plainPath, groupPath)
941 mustBeOld(t, passwdPath)
945 mustWriteConfig(t, fmt.Sprintf(`
953 `, statePath, a.url, passwdPath, tlsCA2Path))
954 mustCreate(t, passwdPath)
955 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
957 *a.handler = func(w http.ResponseWriter, r *http.Request) {
958 if r.URL.Path == "/passwd" {
959 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
963 err = mainFetch(configPath)
964 mustBeErrorWithSubstring(t, err,
965 "x509: certificate signed by unknown authority")
967 mustNotExist(t, statePath, plainPath, groupPath)
968 mustBeOld(t, passwdPath)
972 TODO: implement code for this test
974 func fetchCannotDeployMultiple(a args) {
976 newPlainDir := "testdata/x"
977 newPlainPath := newPlainDir + "/plain"
978 mustWriteConfig(t, fmt.Sprintf(`
990 `, statePath, a.url, groupPath, newPlainPath))
991 os.Mkdir(newPlainDir, 0755)
992 defer os.RemoveAll(newPlainDir)
993 mustCreate(t, groupPath)
994 mustCreate(t, newPlainPath)
995 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
997 *a.handler = func(w http.ResponseWriter, r *http.Request) {
998 if r.URL.Path == "/group" {
999 fmt.Fprintln(w, "root:x:0:")
1001 if r.URL.Path == "/plain" {
1002 fmt.Fprintln(w, "some file")
1006 err := os.Chmod(newPlainDir, 0500)
1011 err = mainFetch(configPath)
1012 mustBeErrorWithSubstring(t, err,
1013 "permission denied")
1015 mustNotExist(t, statePath, passwdPath, plainPath)
1016 mustBeOld(t, groupPath, newPlainPath)