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/>.
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,
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
273 dir := filepath.Dir(p)
274 err = os.MkdirAll(dir, 0755)
278 defer os.Remove(dir) // remove empty directories
281 var handler func(http.ResponseWriter, *http.Request)
282 ts := httptest.NewUnstartedServer(http.HandlerFunc(
283 func(w http.ResponseWriter, r *http.Request) {
302 func fetchPasswdCacheFileDoesNotExist(a args) {
304 mustWritePasswdConfig(t, a.url)
306 err := mainFetch(configPath)
307 mustBeErrorWithSubstring(t, err,
308 "file.path \""+passwdPath+"\" must exist")
310 mustNotExist(t, statePath, passwdPath, plainPath, groupPath)
313 func fetchPasswd404(a args) {
315 mustWritePasswdConfig(t, a.url)
316 mustCreate(t, passwdPath)
318 *a.handler = func(w http.ResponseWriter, r *http.Request) {
320 w.WriteHeader(http.StatusNotFound)
323 err := mainFetch(configPath)
324 mustBeErrorWithSubstring(t, err,
327 mustNotExist(t, statePath, plainPath, groupPath)
328 mustBeOld(t, passwdPath)
331 func fetchPasswdUnexpected304(a args) {
333 mustWritePasswdConfig(t, a.url)
334 mustCreate(t, passwdPath)
336 *a.handler = func(w http.ResponseWriter, r *http.Request) {
338 w.WriteHeader(http.StatusNotModified)
341 err := mainFetch(configPath)
342 mustBeErrorWithSubstring(t, err,
343 "status code 304 but did not send If-Modified-Since")
345 mustNotExist(t, statePath, plainPath, groupPath)
346 mustBeOld(t, passwdPath)
349 func fetchPasswdEmpty(a args) {
351 mustWritePasswdConfig(t, a.url)
352 mustCreate(t, passwdPath)
354 *a.handler = func(w http.ResponseWriter, r *http.Request) {
358 err := mainFetch(configPath)
359 mustBeErrorWithSubstring(t, err,
360 "refusing to use empty passwd file")
362 mustNotExist(t, statePath, plainPath, groupPath)
363 mustBeOld(t, passwdPath)
366 func fetchPasswdInvalid(a args) {
368 mustWritePasswdConfig(t, a.url)
369 mustCreate(t, passwdPath)
371 *a.handler = func(w http.ResponseWriter, r *http.Request) {
372 if r.URL.Path != "/passwd" {
376 fmt.Fprintln(w, "root:x:invalid:0:root:/root:/bin/bash")
379 err := mainFetch(configPath)
380 mustBeErrorWithSubstring(t, err,
381 "invalid uid in line")
383 mustNotExist(t, statePath, plainPath, groupPath)
384 mustBeOld(t, passwdPath)
387 func fetchPasswdLimits(a args) {
389 mustWritePasswdConfig(t, a.url)
390 mustCreate(t, passwdPath)
392 *a.handler = func(w http.ResponseWriter, r *http.Request) {
393 if r.URL.Path != "/passwd" {
397 fmt.Fprint(w, "root:x:0:0:root:/root:/bin/bash")
398 for i := 0; i < 65536; i++ {
404 err := mainFetch(configPath)
405 mustBeErrorWithSubstring(t, err,
406 "passwd too large to serialize")
408 mustNotExist(t, statePath, plainPath, groupPath)
409 mustBeOld(t, passwdPath)
412 func fetchPasswd(a args) {
414 mustWritePasswdConfig(t, a.url)
415 mustCreate(t, passwdPath)
416 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
418 t.Log("First fetch, write files")
420 *a.handler = func(w http.ResponseWriter, r *http.Request) {
421 if r.URL.Path != "/passwd" {
425 // No "Last-Modified" header
426 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
427 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
430 err := mainFetch(configPath)
435 mustNotExist(t, plainPath, groupPath)
436 mustBeNew(t, passwdPath, statePath)
437 // The actual content of passwdPath is verified by the NSS tests
438 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
440 t.Log("Fetch again, no support for Last-Modified")
442 mustMakeOld(t, passwdPath, statePath)
444 err = mainFetch(configPath)
449 mustNotExist(t, plainPath, groupPath)
450 mustBeNew(t, passwdPath, statePath)
451 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
453 t.Log("Fetch again, support for Last-Modified, but not retrieved yet")
455 mustMakeOld(t, passwdPath, statePath)
457 lastChange := time.Now()
459 *a.handler = func(w http.ResponseWriter, r *http.Request) {
460 if r.URL.Path != "/passwd" {
464 modified := r.Header.Get("If-Modified-Since")
466 x, err := http.ParseTime(modified)
468 t.Fatalf("invalid If-Modified-Since %v",
471 if !x.Before(lastChange.Truncate(time.Second)) {
472 w.WriteHeader(http.StatusNotModified)
477 w.Header().Add("Last-Modified",
478 lastChange.UTC().Format(http.TimeFormat))
479 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
480 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
482 fmt.Fprintln(w, "bin:x:2:2:bin:/bin:/usr/sbin/nologin")
486 err = mainFetch(configPath)
491 mustNotExist(t, plainPath, groupPath)
492 mustBeNew(t, passwdPath, statePath)
493 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
495 t.Log("Fetch again, support for Last-Modified")
497 mustMakeOld(t, passwdPath, statePath)
499 err = mainFetch(configPath)
504 mustNotExist(t, plainPath, groupPath)
505 mustBeOld(t, passwdPath)
506 mustBeNew(t, statePath)
507 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
509 t.Log("Corrupt local passwd cache, fetched again")
511 os.Chmod(passwdPath, 0644) // make writable again
512 mustCreate(t, passwdPath)
513 mustMakeOld(t, passwdPath, statePath)
515 err = mainFetch(configPath)
520 mustNotExist(t, plainPath, groupPath)
521 mustBeNew(t, passwdPath, statePath)
522 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
524 t.Log("Fetch again with newer server response")
527 lastChange = time.Now().Add(time.Second)
529 mustMakeOld(t, passwdPath, statePath)
531 err = mainFetch(configPath)
536 mustNotExist(t, plainPath, groupPath)
537 mustBeNew(t, passwdPath, statePath)
538 mustHaveHash(t, passwdPath, "ca9c7477cb425667fc9ecbd79e8e1c2ad0e84423")
541 func fetchPlainEmpty(a args) {
543 mustWriteConfig(t, fmt.Sprintf(`
551 `, statePath, a.url, plainPath, tlsCAPath))
552 mustCreate(t, plainPath)
554 *a.handler = func(w http.ResponseWriter, r *http.Request) {
558 err := mainFetch(configPath)
559 mustBeErrorWithSubstring(t, err,
560 "refusing to use empty response")
562 mustNotExist(t, statePath, passwdPath, groupPath)
563 mustBeOld(t, plainPath)
566 func fetchPlain(a args) {
568 mustWriteConfig(t, fmt.Sprintf(`
576 `, statePath, a.url, plainPath, tlsCAPath))
577 mustCreate(t, plainPath)
578 mustHaveHash(t, plainPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
580 *a.handler = func(w http.ResponseWriter, r *http.Request) {
581 if r.URL.Path != "/plain" {
585 fmt.Fprintln(w, "some file")
588 err := mainFetch(configPath)
593 mustNotExist(t, passwdPath, groupPath)
594 mustBeNew(t, plainPath, statePath)
595 mustHaveHash(t, plainPath, "0e08b5e8c10abc3e455b75286ba4a1fbd56e18a5")
597 // Remaining functionality already tested in fetchPasswd()
600 func fetchGroupEmpty(a args) {
602 mustWriteGroupConfig(t, a.url)
603 mustCreate(t, groupPath)
605 *a.handler = func(w http.ResponseWriter, r *http.Request) {
609 err := mainFetch(configPath)
610 mustBeErrorWithSubstring(t, err,
611 "refusing to use empty group file")
613 mustNotExist(t, statePath, passwdPath, plainPath)
614 mustBeOld(t, groupPath)
617 func fetchGroupInvalid(a args) {
619 mustWriteGroupConfig(t, a.url)
620 mustCreate(t, groupPath)
622 *a.handler = func(w http.ResponseWriter, r *http.Request) {
623 if r.URL.Path != "/group" {
627 fmt.Fprintln(w, "root:x::")
630 err := mainFetch(configPath)
631 mustBeErrorWithSubstring(t, err,
632 "invalid gid in line")
634 mustNotExist(t, statePath, passwdPath, plainPath)
635 mustBeOld(t, groupPath)
638 func fetchGroupLimits(a args) {
640 mustWriteGroupConfig(t, a.url)
641 mustCreate(t, groupPath)
643 *a.handler = func(w http.ResponseWriter, r *http.Request) {
644 if r.URL.Path != "/group" {
648 fmt.Fprint(w, "root:x:0:")
649 for i := 0; i < 65536; i++ {
655 err := mainFetch(configPath)
656 mustBeErrorWithSubstring(t, err,
657 "group too large to serialize")
659 mustNotExist(t, statePath, passwdPath, plainPath)
660 mustBeOld(t, groupPath)
663 func fetchGroup(a args) {
665 mustWriteGroupConfig(t, a.url)
666 mustCreate(t, groupPath)
667 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
669 *a.handler = func(w http.ResponseWriter, r *http.Request) {
670 if r.URL.Path != "/group" {
674 fmt.Fprintln(w, "root:x:0:")
675 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
678 err := mainFetch(configPath)
683 mustNotExist(t, passwdPath, plainPath)
684 mustBeNew(t, groupPath, statePath)
685 // The actual content of groupPath is verified by the NSS tests
686 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
688 // Remaining functionality already tested in fetchPasswd()
691 func fetchNoConfig(a args) {
694 err := mainFetch(configPath)
695 mustBeErrorWithSubstring(t, err,
696 configPath+": no such file or directory")
698 mustNotExist(t, configPath, statePath, passwdPath, plainPath, groupPath)
701 func fetchStateCannotRead(a args) {
703 mustWritePasswdConfig(t, a.url)
705 mustCreate(t, statePath)
706 err := os.Chmod(statePath, 0000)
711 err = mainFetch(configPath)
712 mustBeErrorWithSubstring(t, err,
713 statePath+": permission denied")
715 mustNotExist(t, passwdPath, plainPath, groupPath)
716 mustBeOld(t, statePath)
719 func fetchStateInvalid(a args) {
721 mustWriteGroupConfig(t, a.url)
722 mustCreate(t, statePath)
724 err := mainFetch(configPath)
725 mustBeErrorWithSubstring(t, err,
726 "unexpected end of JSON input")
728 mustNotExist(t, groupPath, passwdPath, plainPath)
729 mustBeOld(t, statePath)
732 func fetchStateCannotWrite(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(filepath.Dir(statePath), 0500)
751 defer os.Chmod(filepath.Dir(statePath), 0755)
753 err = mainFetch(configPath)
754 mustBeErrorWithSubstring(t, err,
757 mustNotExist(t, statePath, passwdPath, plainPath)
758 mustBeNew(t, groupPath)
759 mustHaveHash(t, groupPath, "8c27a8403278ba2e392b86d98d4dff1fdefcafdd")
762 func fetchCannotDeploy(a args) {
764 mustWriteGroupConfig(t, a.url)
765 mustCreate(t, groupPath)
766 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
768 *a.handler = func(w http.ResponseWriter, r *http.Request) {
769 if r.URL.Path != "/group" {
773 fmt.Fprintln(w, "root:x:0:")
774 fmt.Fprintln(w, "daemon:x:1:andariel,duriel,mephisto,diablo,baal")
777 err := os.Chmod("testdata", 0500)
781 defer os.Chmod("testdata", 0755)
783 err = mainFetch(configPath)
784 mustBeErrorWithSubstring(t, err,
787 mustNotExist(t, statePath, passwdPath, plainPath)
788 mustBeOld(t, groupPath)
791 func fetchSecondFetchFails(a args) {
793 mustWriteConfig(t, fmt.Sprintf(`
807 `, statePath, a.url, passwdPath, groupPath, tlsCAPath))
808 mustCreate(t, passwdPath)
809 mustCreate(t, groupPath)
810 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
811 mustHaveHash(t, groupPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
813 *a.handler = func(w http.ResponseWriter, r *http.Request) {
814 if r.URL.Path == "/passwd" {
815 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
817 if r.URL.Path == "/group" {
818 w.WriteHeader(http.StatusNotFound)
822 err := mainFetch(configPath)
823 mustBeErrorWithSubstring(t, err,
826 mustNotExist(t, statePath, plainPath)
827 // Even though passwd was successfully fetched, no files were modified
828 // because the second fetch failed
829 mustBeOld(t, passwdPath, groupPath)
832 func fetchBasicAuth(a args) {
834 mustWritePasswdConfig(t, a.url)
835 mustCreate(t, passwdPath)
836 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
838 validUser := "username"
839 validPass := "password"
841 *a.handler = func(w http.ResponseWriter, r *http.Request) {
842 if r.URL.Path != "/passwd" {
846 user, pass, ok := r.BasicAuth()
847 // NOTE: Do not use this in production because it permits
848 // attackers to determine the length of user/pass. Instead use
849 // hashes and subtle.ConstantTimeCompare().
850 if !ok || user != validUser || pass != validPass {
851 w.Header().Set("WWW-Authenticate", `Basic realm="Test"`)
852 w.WriteHeader(http.StatusUnauthorized)
856 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
857 fmt.Fprintln(w, "daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin")
860 t.Log("Missing authentication")
862 err := mainFetch(configPath)
863 mustBeErrorWithSubstring(t, err,
866 mustNotExist(t, statePath, groupPath, plainPath)
867 mustBeOld(t, passwdPath)
869 t.Log("Unsafe config permissions")
871 mustWriteConfig(t, fmt.Sprintf(`
881 `, statePath, a.url, passwdPath, tlsCAPath, validUser, validPass))
883 err = os.Chmod(configPath, 0644)
888 err = mainFetch(configPath)
889 mustBeErrorWithSubstring(t, err,
890 "file[0].username/passsword in use and unsafe permissions "+
891 "-rw-r--r-- on \"testdata/config.toml\"")
893 mustNotExist(t, statePath, groupPath, plainPath)
894 mustBeOld(t, passwdPath)
896 t.Log("Working authentication")
898 err = os.Chmod(configPath, 0600)
903 err = mainFetch(configPath)
908 mustNotExist(t, plainPath, groupPath)
909 mustBeNew(t, passwdPath, statePath)
910 mustHaveHash(t, passwdPath, "bbb7db67469b111200400e2470346d5515d64c23")
913 func fetchInvalidCA(a args) {
918 mustWriteConfig(t, fmt.Sprintf(`
925 `, statePath, a.url, passwdPath))
926 mustCreate(t, passwdPath)
927 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
929 *a.handler = func(w http.ResponseWriter, r *http.Request) {
930 if r.URL.Path == "/passwd" {
931 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
935 err := mainFetch(configPath)
936 mustBeErrorWithSubstring(t, err,
937 "x509: certificate signed by unknown authority")
939 mustNotExist(t, statePath, plainPath, groupPath)
940 mustBeOld(t, passwdPath)
944 mustWriteConfig(t, fmt.Sprintf(`
952 `, statePath, a.url, passwdPath, tlsCA2Path))
953 mustCreate(t, passwdPath)
954 mustHaveHash(t, passwdPath, "da39a3ee5e6b4b0d3255bfef95601890afd80709")
956 *a.handler = func(w http.ResponseWriter, r *http.Request) {
957 if r.URL.Path == "/passwd" {
958 fmt.Fprintln(w, "root:x:0:0:root:/root:/bin/bash")
962 err = mainFetch(configPath)
963 mustBeErrorWithSubstring(t, err,
964 "x509: certificate signed by unknown authority")
966 mustNotExist(t, statePath, plainPath, groupPath)
967 mustBeOld(t, passwdPath)