1 // Copyright (C) 2019 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/>.
36 configPath = "testdata/config.toml"
37 statePath = "testdata/state.json"
38 passwdPath = "testdata/passwd.nsscash"
39 plainPath = "testdata/plain"
40 groupPath = "testdata/group.nsscash"
41 tlsCAPath = "testdata/ca.crt"
42 tlsCertPath = "testdata/server.crt"
43 tlsKeyPath = "testdata/server.key"
44 tlsCA2Path = "testdata/ca2.crt"
50 handler *func(http.ResponseWriter, *http.Request)
53 // mustNotExist verifies that all given paths don't exist in the file system.
54 func mustNotExist(t *testing.T, paths ...string) {
55 for _, p := range paths {
58 if !os.IsNotExist(err) {
59 t.Errorf("path %q: unexpected error: %v",
63 t.Errorf("path %q exists", p)
69 func hashAsHex(x []byte) string {
72 return hex.EncodeToString(h.Sum(nil))
75 // mustHaveHash checks if the given path content has the given SHA-1 string
77 func mustHaveHash(t *testing.T, path string, hash string) {
78 x, err := ioutil.ReadFile(path)
85 t.Errorf("%q has unexpected hash %q", path, y)
89 // mustBeErrorWithSubstring checks if the given error, represented as string,
90 // contains the given substring. This is somewhat ugly but the simplest way to
91 // check for proper errors.
92 func mustBeErrorWithSubstring(t *testing.T, err error, substring string) {
94 t.Errorf("err is nil")
95 } else if !strings.Contains(err.Error(), substring) {
96 t.Errorf("err %q does not contain string %q", err, substring)
100 func mustWriteConfig(t *testing.T, config string) {
101 err := ioutil.WriteFile(configPath, []byte(config), 0644)
107 func mustWritePasswdConfig(t *testing.T, url string) {
108 mustWriteConfig(t, fmt.Sprintf(`
116 `, statePath, url, passwdPath, tlsCAPath))
119 func mustWriteGroupConfig(t *testing.T, url string) {
120 mustWriteConfig(t, fmt.Sprintf(`
128 `, statePath, url, groupPath, tlsCAPath))
131 // mustCreate creates a file, truncating it if it exists. It then changes the
132 // modification to be in the past.
133 func mustCreate(t *testing.T, path string) {
134 f, err := os.Create(path)
143 // Change modification time to the past to detect updates to the file
147 // mustMakeOld change the modification time of all paths to be in the past.
148 func mustMakeOld(t *testing.T, paths ...string) {
149 old := time.Now().Add(-2 * time.Hour)
150 for _, p := range paths {
151 err := os.Chtimes(p, old, old)
158 // mustMakeOld verifies that all paths have a modification time in the past,
159 // as set by mustMakeOld().
160 func mustBeOld(t *testing.T, paths ...string) {
161 for _, p := range paths {
169 if now.Sub(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 {
185 if now.Sub(mtime) > time.Hour {
186 t.Errorf("%q was not recently modified", p)
191 func TestMainFetch(t *testing.T) {
192 // Suppress log messages
193 log.SetOutput(ioutil.Discard)
194 defer log.SetOutput(os.Stderr)
196 tests := []func(args){
197 // Perform most tests with passwd for simplicity
198 fetchPasswdCacheFileDoesNotExist,
204 // Tests for plain and group
213 fetchStateCannotRead,
215 fetchStateCannotWrite,
217 fetchSecondFetchFails,
223 for _, f := range tests {
224 runMainTest(t, f, nil)
229 tests = append(tests, fetchInvalidCA)
231 cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath)
236 Certificates: []tls.Certificate{cert},
239 for _, f := range tests {
240 runMainTest(t, f, tls)
244 func runMainTest(t *testing.T, f func(args), tls *tls.Config) {
253 // NOTE: This is not guaranteed to work according to reflect's
254 // documentation but seems to work reliable for normal functions.
255 fn := runtime.FuncForPC(reflect.ValueOf(f).Pointer())
257 name = name[strings.LastIndex(name, ".")+1:]
262 t.Run(name, func(t *testing.T) {
263 // Preparation & cleanup
264 for _, p := range cleanup {
266 if err != nil && !os.IsNotExist(err) {
269 // Remove the file at the end of this test run, if it
274 var handler func(http.ResponseWriter, *http.Request)
275 ts := httptest.NewUnstartedServer(http.HandlerFunc(
276 func(w http.ResponseWriter, r *http.Request) {
295 func fetchPasswdCacheFileDoesNotExist(a args) {
297 mustWritePasswdConfig(t, a.url)
299 err := mainFetch(configPath)
300 mustBeErrorWithSubstring(t, err,
301 "file.path \""+passwdPath+"\" must exist")
303 mustNotExist(t, statePath, passwdPath, plainPath, groupPath)
306 func fetchPasswd404(a args) {
308 mustWritePasswdConfig(t, a.url)
309 mustCreate(t, passwdPath)
311 *a.handler = func(w http.ResponseWriter, r *http.Request) {
313 w.WriteHeader(http.StatusNotFound)
316 err := mainFetch(configPath)
317 mustBeErrorWithSubstring(t, err,
320 mustNotExist(t, statePath, plainPath, groupPath)
321 mustBeOld(a.t, passwdPath)
324 func fetchPasswdEmpty(a args) {
326 mustWritePasswdConfig(t, a.url)
327 mustCreate(t, passwdPath)
329 *a.handler = func(w http.ResponseWriter, r *http.Request) {
333 err := mainFetch(configPath)
334 mustBeErrorWithSubstring(t, err,
335 "refusing to use empty passwd file")
337 mustNotExist(t, statePath, plainPath, groupPath)
338 mustBeOld(t, passwdPath)
341 func fetchPasswdInvalid(a args) {
343 mustWritePasswdConfig(t, a.url)
344 mustCreate(t, passwdPath)
346 *a.handler = func(w http.ResponseWriter, r *http.Request) {
347 if r.URL.Path != "/passwd" {
351 fmt.Fprintln(w, "root:x:invalid:0:root:/root:/bin/bash")
354 err := mainFetch(configPath)
355 mustBeErrorWithSubstring(t, err,
356 "invalid uid in line")
358 mustNotExist(t, statePath, plainPath, groupPath)
359 mustBeOld(t, passwdPath)
362 func fetchPasswdLimits(a args) {
364 mustWritePasswdConfig(t, a.url)
365 mustCreate(t, passwdPath)
367 *a.handler = func(w http.ResponseWriter, r *http.Request) {
368 if r.URL.Path != "/passwd" {
372 fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash")
373 for i := 0; i < 65536; i++ {
379 err := mainFetch(configPath)
380 mustBeErrorWithSubstring(t, err,
381 "passwd too large to serialize")
383 mustNotExist(t, statePath, plainPath, groupPath)
384 mustBeOld(t, passwdPath)
387 func fetchPasswd(a args) {
389 mustWritePasswdConfig(t, a.url)
390 mustCreate(t, passwdPath)
391 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
393 t.Log("First fetch, write files")
395 *a.handler = func(w http.ResponseWriter, r *http.Request) {
396 if r.URL.Path != "/passwd" {
400 // No "Last-Modified" header
401 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
402 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
405 err := mainFetch(configPath)
410 mustNotExist(t, plainPath, groupPath)
411 mustBeNew(t, passwdPath, statePath)
412 // The actual content of passwdPath is verified by the NSS tests
413 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
415 t.Log("Fetch again, no support for Last-Modified")
417 mustMakeOld(t, passwdPath, statePath)
419 err = mainFetch(configPath)
424 mustNotExist(t, plainPath, groupPath)
425 mustBeNew(t, passwdPath, statePath)
426 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
428 t.Log("Fetch again, support for Last-Modified, but not retrieved yet")
430 mustMakeOld(t, passwdPath, statePath)
432 lastChange := time.Now()
434 *a.handler = func(w http.ResponseWriter, r *http.Request) {
435 if r.URL.Path != "/passwd" {
439 modified := r.Header.Get("If-Modified-Since")
441 x, err := http.ParseTime(modified)
443 t.Fatalf("invalid If-Modified-Since %v",
446 if !x.Before(lastChange.Truncate(time.Second)) {
447 w.WriteHeader(http.StatusNotModified)
452 w.Header().Add("Last-Modified",
453 lastChange.UTC().Format(http.TimeFormat))
454 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
455 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
457 fmt.Fprintln(w, "bin:x:2:2:bin:/bin:/usr/sbin/nologin")
461 err = mainFetch(configPath)
466 mustNotExist(t, plainPath, groupPath)
467 mustBeNew(t, passwdPath, statePath)
468 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
470 t.Log("Fetch again, support for Last-Modified")
472 mustMakeOld(t, passwdPath, statePath)
474 err = mainFetch(configPath)
479 mustNotExist(t, plainPath, groupPath)
480 mustBeOld(t, passwdPath)
481 mustBeNew(t, statePath)
482 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
484 t.Log("Corrupt local passwd cache, fetched again")
486 os.Chmod(passwdPath, 0644) // make writable again
487 mustCreate(t, passwdPath)
488 mustMakeOld(t, passwdPath, statePath)
490 err = mainFetch(configPath)
495 mustNotExist(t, plainPath, groupPath)
496 mustBeNew(t, passwdPath, statePath)
497 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
499 t.Log("Fetch again with newer server response")
502 lastChange = time.Now().Add(time.Second)
504 mustMakeOld(t, passwdPath, statePath)
506 err = mainFetch(configPath)
511 mustNotExist(t, plainPath, groupPath)
512 mustBeNew(t, passwdPath, statePath)
513 mustHaveHash(t, passwdPath, "ca9c7477cb425667fc9ecbd79e8e1c2ad0e84423")
516 func fetchPlainEmpty(a args) {
518 mustWriteConfig(t, fmt.Sprintf(`
526 `, statePath, a.url, plainPath, tlsCAPath))
527 mustCreate(t, plainPath)
529 *a.handler = func(w http.ResponseWriter, r *http.Request) {
533 err := mainFetch(configPath)
534 mustBeErrorWithSubstring(t, err,
535 "refusing to use empty response")
537 mustNotExist(t, statePath, passwdPath, groupPath)
538 mustBeOld(t, plainPath)
541 func fetchPlain(a args) {
543 mustWriteConfig(t, fmt.Sprintf(`
551 `, statePath, a.url, plainPath, tlsCAPath))
552 mustCreate(t, plainPath)
553 mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
555 *a.handler = func(w http.ResponseWriter, r *http.Request) {
556 if r.URL.Path != "/plain" {
560 fmt.Fprintln(w, "some file")
563 err := mainFetch(configPath)
568 mustNotExist(t, passwdPath, groupPath)
569 mustBeNew(t, plainPath, statePath)
570 mustHaveHash(t, plainPath, "0e08b5e8c10abc3e455b75286ba4a1fbd56e18a5")
572 // Remaining functionality already tested in fetchPasswd()
575 func fetchGroupEmpty(a args) {
577 mustWriteGroupConfig(t, a.url)
578 mustCreate(t, groupPath)
580 *a.handler = func(w http.ResponseWriter, r *http.Request) {
584 err := mainFetch(configPath)
585 mustBeErrorWithSubstring(t, err,
586 "refusing to use empty group file")
588 mustNotExist(t, statePath, passwdPath, plainPath)
589 mustBeOld(t, groupPath)
592 func fetchGroupInvalid(a args) {
594 mustWriteGroupConfig(t, a.url)
595 mustCreate(t, groupPath)
597 *a.handler = func(w http.ResponseWriter, r *http.Request) {
598 if r.URL.Path != "/group" {
602 fmt.Fprintln(w, "root:x::")
605 err := mainFetch(configPath)
606 mustBeErrorWithSubstring(t, err,
607 "invalid gid in line")
609 mustNotExist(t, statePath, passwdPath, plainPath)
610 mustBeOld(t, groupPath)
613 func fetchGroupLimits(a args) {
615 mustWriteGroupConfig(t, a.url)
616 mustCreate(t, groupPath)
618 *a.handler = func(w http.ResponseWriter, r *http.Request) {
619 if r.URL.Path != "/group" {
623 fmt.Fprint(w, "root:x:0:")
624 for i := 0; i < 65536; i++ {
630 err := mainFetch(configPath)
631 mustBeErrorWithSubstring(t, err,
632 "group too large to serialize")
634 mustNotExist(t, statePath, passwdPath, plainPath)
635 mustBeOld(t, groupPath)
638 func fetchGroup(a args) {
640 mustWriteGroupConfig(t, a.url)
641 mustCreate(t, groupPath)
642 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
644 *a.handler = func(w http.ResponseWriter, r *http.Request) {
645 if r.URL.Path != "/group" {
649 fmt.Fprintln(w, "root:x:0:")
650 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
653 err := mainFetch(configPath)
658 mustNotExist(t, passwdPath, plainPath)
659 mustBeNew(t, groupPath, statePath)
660 // The actual content of groupPath is verified by the NSS tests
661 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
663 // Remaining functionality already tested in fetchPasswd()
666 func fetchNoConfig(a args) {
669 err := mainFetch(configPath)
670 mustBeErrorWithSubstring(t, err,
671 configPath+": no such file or directory")
673 mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
676 func fetchStateCannotRead(a args) {
678 mustWritePasswdConfig(t, a.url)
680 mustCreate(t, statePath)
681 err := os.Chmod(statePath, 0000)
686 err = mainFetch(configPath)
687 mustBeErrorWithSubstring(t, err,
688 statePath+": permission denied")
690 mustNotExist(t, passwdPath, plainPath, groupPath)
693 func fetchStateInvalid(a args) {
695 mustWriteGroupConfig(t, a.url)
696 mustCreate(t, statePath)
698 err := mainFetch(configPath)
699 mustBeErrorWithSubstring(t, err,
700 "unexpected end of JSON input")
702 mustNotExist(t, groupPath, passwdPath, plainPath)
703 mustBeOld(t, statePath)
706 func fetchStateCannotWrite(a args) {
708 mustWriteGroupConfig(t, a.url)
709 mustCreate(t, groupPath)
710 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
712 *a.handler = func(w http.ResponseWriter, r *http.Request) {
713 // To prevent mainFetch() from trying to update groupPath
714 // which will also fail
715 w.WriteHeader(http.StatusNotModified)
718 err := os.Chmod("testdata", 0500)
722 defer os.Chmod("testdata", 0755)
724 err = mainFetch(configPath)
725 mustBeErrorWithSubstring(t, err,
728 mustNotExist(t, statePath, passwdPath, plainPath)
729 mustBeOld(t, groupPath)
732 func fetchCannotDeploy(a args) {
734 mustWriteGroupConfig(t, a.url)
735 mustCreate(t, groupPath)
736 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
738 *a.handler = func(w http.ResponseWriter, r *http.Request) {
739 if r.URL.Path != "/group" {
743 fmt.Fprintln(w, "root:x:0:")
744 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
747 err := os.Chmod("testdata", 0500)
751 defer os.Chmod("testdata", 0755)
753 err = mainFetch(configPath)
754 mustBeErrorWithSubstring(t, err,
757 mustNotExist(t, statePath, passwdPath, plainPath)
758 mustBeOld(t, groupPath)
761 func fetchSecondFetchFails(a args) {
763 mustWriteConfig(t, fmt.Sprintf(`
777 `, statePath, a.url, passwdPath, groupPath, tlsCAPath))
778 mustCreate(t, passwdPath)
779 mustCreate(t, groupPath)
780 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
781 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
783 *a.handler = func(w http.ResponseWriter, r *http.Request) {
784 if r.URL.Path == "/passwd" {
785 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
787 if r.URL.Path == "/group" {
788 w.WriteHeader(http.StatusNotFound)
792 err := mainFetch(configPath)
793 mustBeErrorWithSubstring(t, err,
796 mustNotExist(t, statePath, plainPath)
797 // Even though passwd was successfully fetched, no files were modified
798 // because the second fetch failed
799 mustBeOld(t, passwdPath, groupPath)
802 func fetchBasicAuth(a args) {
804 mustWritePasswdConfig(t, a.url)
805 mustCreate(t, passwdPath)
806 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
808 validUser := "username"
809 validPass := "password"
811 *a.handler = func(w http.ResponseWriter, r *http.Request) {
812 if r.URL.Path != "/passwd" {
816 user, pass, ok := r.BasicAuth()
817 // NOTE: Do not use this in production because it permits
818 // attackers to determine the length of user/pass. Instead use
819 // hashes and subtle.ConstantTimeCompare().
820 if !ok || user != validUser || pass != validPass {
821 w.Header().Set("WWW-Authenticate", `Basic realm="Test"`)
822 w.WriteHeader(http.StatusUnauthorized)
826 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
827 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
830 t.Log("Missing authentication")
832 err := mainFetch(configPath)
833 mustBeErrorWithSubstring(t, err,
836 mustNotExist(t, statePath, groupPath, plainPath)
837 mustBeOld(t, passwdPath)
839 t.Log("Unsafe config permissions")
841 mustWriteConfig(t, fmt.Sprintf(`
851 `, statePath, a.url, passwdPath, tlsCAPath, validUser, validPass))
853 err = os.Chmod(configPath, 0644)
858 err = mainFetch(configPath)
859 mustBeErrorWithSubstring(t, err,
860 "file[0].username/passsword in use and unsafe permissions "+
861 "-rw-r--r-- on \"testdata/config.toml\"")
863 mustNotExist(t, statePath, groupPath, plainPath)
864 mustBeOld(t, passwdPath)
866 t.Log("Working authentication")
868 err = os.Chmod(configPath, 0600)
873 err = mainFetch(configPath)
878 mustNotExist(t, plainPath, groupPath)
879 mustBeNew(t, passwdPath, statePath)
880 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
883 func fetchInvalidCA(a args) {
888 mustWriteConfig(t, fmt.Sprintf(`
895 `, statePath, a.url, passwdPath))
896 mustCreate(t, passwdPath)
897 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
899 *a.handler = func(w http.ResponseWriter, r *http.Request) {
900 if r.URL.Path == "/passwd" {
901 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
905 err := mainFetch(configPath)
906 mustBeErrorWithSubstring(t, err,
907 "x509: certificate signed by unknown authority")
909 mustNotExist(t, statePath, plainPath, groupPath)
910 mustBeOld(t, passwdPath)
914 mustWriteConfig(t, fmt.Sprintf(`
922 `, statePath, a.url, passwdPath, tlsCA2Path))
923 mustCreate(t, passwdPath)
924 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
926 *a.handler = func(w http.ResponseWriter, r *http.Request) {
927 if r.URL.Path == "/passwd" {
928 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
932 err = mainFetch(configPath)
933 mustBeErrorWithSubstring(t, err,
934 "x509: certificate signed by unknown authority")
936 mustNotExist(t, statePath, plainPath, groupPath)
937 mustBeOld(t, passwdPath)