|
13 | 13 | package main |
14 | 14 |
|
15 | 15 | import ( |
| 16 | + "bytes" |
16 | 17 | "crypto/tls" |
17 | 18 | "crypto/x509" |
18 | 19 | "encoding/json" |
@@ -253,7 +254,7 @@ func runEnroll(jwtPath, identityFile string) error { |
253 | 254 | if err != nil { |
254 | 255 | return fmt.Errorf("marshal identity config: %w", err) |
255 | 256 | } |
256 | | - if err := os.WriteFile(identityFile, cfgJSON, 0600); err != nil { |
| 257 | + if err := config.AtomicWriteFile(identityFile, cfgJSON, 0600); err != nil { |
257 | 258 | return fmt.Errorf("write identity file %q: %w", identityFile, err) |
258 | 259 | } |
259 | 260 | slog.Info("identity enrolled", "path", identityFile) |
@@ -456,8 +457,22 @@ func intCAFromController(controllerURL string, rootPool *x509.CertPool) (*x509.C |
456 | 457 | "selfSigned", cert.Subject.String() == cert.Issuer.String()) |
457 | 458 | } |
458 | 459 |
|
| 460 | + if len(state.PeerCertificates) == 0 { |
| 461 | + return nil, fmt.Errorf("no certificates in TLS chain from %q", host) |
| 462 | + } |
| 463 | + leaf := state.PeerCertificates[0] |
| 464 | + // Walk the chain and pick the cert whose Subject matches the leaf's Issuer |
| 465 | + // field. Raw-bytes comparison avoids encoding differences that string |
| 466 | + // formatting can obscure, and correctly identifies the signing intermediate |
| 467 | + // even when the chain contains cross-signed or multiple intermediate certs. |
| 468 | + for _, cert := range state.PeerCertificates[1:] { |
| 469 | + if cert.IsCA && bytes.Equal(cert.RawSubject, leaf.RawIssuer) { |
| 470 | + return cert, nil |
| 471 | + } |
| 472 | + } |
| 473 | + // Fallback to the original heuristic for unusual chain orderings. |
459 | 474 | for _, cert := range state.PeerCertificates { |
460 | | - if cert.IsCA && cert.Subject.String() != cert.Issuer.String() { |
| 475 | + if cert.IsCA && !bytes.Equal(cert.RawSubject, cert.RawIssuer) { |
461 | 476 | return cert, nil |
462 | 477 | } |
463 | 478 | } |
@@ -674,11 +689,40 @@ func loadPermissionsConfig(zitiCtx ziti.Context, serviceName string) (*host.Perm |
674 | 689 | Permissions: make(map[string]host.IdentityPermissions, len(wire.Permissions)), |
675 | 690 | } |
676 | 691 | for identity, entry := range wire.Permissions { |
| 692 | + if entry.SudoersRule != "" { |
| 693 | + if err := host.ValidateSudoersRule(entry.SudoersRule); err != nil { |
| 694 | + return nil, fmt.Errorf("service %q config: identity %q: %w", serviceName, identity, err) |
| 695 | + } |
| 696 | + } |
677 | 697 | pc.Permissions[identity] = host.IdentityPermissions{ |
678 | 698 | Groups: entry.Groups, |
679 | 699 | SudoersRule: entry.SudoersRule, |
680 | 700 | } |
681 | 701 | } |
| 702 | + // Validate that no two explicit (non-glob) config keys derive to the same |
| 703 | + // Linux username. A collision allows one Ziti identity to connect as another |
| 704 | + // identity's Linux account, potentially inheriting elevated permissions. |
| 705 | + // Glob patterns are skipped — they match many identities and have no single |
| 706 | + // derived username. Runtime collision detection in UserManager.EnsureUser |
| 707 | + // handles the glob case. |
| 708 | + derived := make(map[string]string, len(pc.Permissions)) |
| 709 | + var collisions []string |
| 710 | + for identity := range pc.Permissions { |
| 711 | + if strings.ContainsAny(identity, "*?") { |
| 712 | + continue |
| 713 | + } |
| 714 | + uname := ca.DeriveUsername(identity) |
| 715 | + if prev, exists := derived[uname]; exists { |
| 716 | + collisions = append(collisions, fmt.Sprintf("%q and %q both derive to %q", prev, identity, uname)) |
| 717 | + } else { |
| 718 | + derived[uname] = identity |
| 719 | + } |
| 720 | + } |
| 721 | + if len(collisions) > 0 { |
| 722 | + return nil, fmt.Errorf("service %q config has username collisions (fix or remove one of each pair): %s", |
| 723 | + serviceName, strings.Join(collisions, "; ")) |
| 724 | + } |
| 725 | + |
682 | 726 | slog.Info("loaded ziti-ssh-host.v1 config", "service", serviceName, "identities", len(pc.Permissions)) |
683 | 727 | return pc, nil |
684 | 728 | } |
@@ -922,7 +966,7 @@ func runProxy(identityFile string, sshServices []string, mode string, zitiTimeou |
922 | 966 | "username", username, |
923 | 967 | "groups", perms.Groups, |
924 | 968 | "has_sudoers", perms.SudoersRule != "") |
925 | | - return mgr.EnsureUser(username, perms) |
| 969 | + return mgr.EnsureUser(zitiIdentity, username, perms) |
926 | 970 | }, |
927 | 971 | OnDisconnect: func(zitiIdentity string) { |
928 | 972 | username := ca.DeriveUsername(zitiIdentity) |
@@ -958,6 +1002,10 @@ func runProxy(identityFile string, sshServices []string, mode string, zitiTimeou |
958 | 1002 | go func() { |
959 | 1003 | sig := <-sigCh |
960 | 1004 | slog.Info("received signal, stopping proxy listeners", "signal", sig) |
| 1005 | + // Restore default signal handling so a second SIGINT/SIGTERM exits |
| 1006 | + // immediately if the drain hangs (instead of being silently swallowed |
| 1007 | + // by the now-drained channel). |
| 1008 | + signal.Reset(syscall.SIGTERM, syscall.SIGINT) |
961 | 1009 | if _, err := daemon.SdNotify(false, "STOPPING=1"); err != nil { |
962 | 1010 | slog.Debug("sd_notify STOPPING failed", "err", err) |
963 | 1011 | } |
|
0 commit comments