Skip to content

Commit

Permalink
Merge pull request gravitational#1863 from gravitational/rjones/tsh-s…
Browse files Browse the repository at this point in the history
…tatus

Added "tsh status" command.
  • Loading branch information
russjones authored Apr 16, 2018
2 parents e02642d + 35d4fbb commit 8e9f8ab
Show file tree
Hide file tree
Showing 4 changed files with 238 additions and 29 deletions.
176 changes: 175 additions & 1 deletion lib/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package client
Expand All @@ -27,12 +26,14 @@ import (
"io"
"io/ioutil"
"net"
"net/url"
"os"
"os/exec"
"os/signal"
"os/user"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
Expand Down Expand Up @@ -212,6 +213,179 @@ func MakeDefaultConfig() *Config {
}
}

// ProfileStatus combines metadata from the logged in profile and associated
// SSH certificate.
type ProfileStatus struct {
// ProxyURL is the URL the web client is accessible at.
ProxyURL url.URL

// Username is the Teleport username.
Username string

// Roles is a list of Teleport Roles this user has been assigned.
Roles []string

// Logins are the Linux accounts, also known as principals in OpenSSH terminology.
Logins []string

// ValidUntil is the time at which this SSH certificate will expire.
ValidUntil time.Time

// Extensions is a list of enabled SSH features for the certificate.
Extensions []string
}

// readProfile reads in the profile as well as the associated certificate
// and returns a *ProfileStatus which can be used to print the status of the
// profile.
func readProfile(profileDir string, profileName string) (*ProfileStatus, error) {
var err error

// Read in the profile for this proxy.
profile, err := ProfileFromFile(filepath.Join(profileDir, profileName))
if err != nil {
return nil, trace.Wrap(err)
}

// Read in the SSH certificate for the user logged into this proxy.
store, err := NewFSLocalKeyStore(profileDir)
if err != nil {
return nil, trace.Wrap(err)
}
keys, err := store.GetKey(profile.ProxyHost, profile.Username)
if err != nil {
return nil, trace.Wrap(err)
}
publicKey, _, _, _, err := ssh.ParseAuthorizedKey(keys.Cert)
if err != nil {
return nil, trace.Wrap(err)
}
cert, ok := publicKey.(*ssh.Certificate)
if !ok {
return nil, trace.BadParameter("no certificate found")
}

// Extract from the certificate how much longer it will be valid for.
validUntil := time.Unix(int64(cert.ValidBefore), 0)

// Extract roles from certificate. Note, if the certificate is in old format,
// this will be empty.
var roles []string
rawRoles, ok := cert.Extensions[teleport.CertExtensionTeleportRoles]
if ok {
roles, err = services.UnmarshalCertRoles(rawRoles)
if err != nil {
return nil, trace.Wrap(err)
}
}
sort.Strings(roles)

// Extract extensions from certificate. This lists the abilities of the
// certificate (like can the user request a PTY, port forwarding, etc.)
var extensions []string
for ext, _ := range cert.Extensions {
if ext == teleport.CertExtensionTeleportRoles {
continue
}
extensions = append(extensions, ext)
}
sort.Strings(extensions)

return &ProfileStatus{
ProxyURL: url.URL{
Scheme: "https",
Host: net.JoinHostPort(profile.ProxyHost, strconv.Itoa(profile.ProxyWebPort)),
},
Username: profile.Username,
Logins: cert.ValidPrincipals,
ValidUntil: validUntil,
Extensions: extensions,
Roles: roles,
}, nil
}

// fullProfileName takes a profile directory and the host the user is trying
// to connect to and returns the name of the profile file.
func fullProfileName(profileDir string, proxyHost string) (string, error) {
var err error
var profileName string

// If no profile name was passed in, try and extract the active profile from
// the ~/.tsh/profile symlink. If one was passed in, append .yaml to name.
if proxyHost == "" {
profileName, err = os.Readlink(filepath.Join(profileDir, "profile"))
if err != nil {
return "", trace.ConvertSystemError(err)
}
} else {
profileName = proxyHost + ".yaml"
}

// Make sure the profile requested actually exists.
_, err = os.Stat(filepath.Join(profileDir, profileName))
if err != nil {
return "", trace.ConvertSystemError(err)
}

return profileName, nil
}

// Status returns the active profile as well as a list of available profiles.
func Status(profileDir string, proxyHost string) (*ProfileStatus, []*ProfileStatus, error) {
var err error
var profile *ProfileStatus
var others []*ProfileStatus

// Construct the full path to the profile requested and make sure it exists.
profileDir = FullProfilePath(profileDir)
stat, err := os.Stat(profileDir)
if err != nil {
return nil, nil, trace.Wrap(err)
}
if !stat.IsDir() {
return nil, nil, trace.BadParameter("profile path not a directory")
}

// Construct the name of the profile requested. If an empty string was
// passed in, the name of the active profile will be extracted from the
// ~/.tsh/profile symlink.
profileName, err := fullProfileName(profileDir, proxyHost)
if err != nil {
return nil, nil, trace.Wrap(err)
}

// Read in the active profile first.
profile, err = readProfile(profileDir, profileName)
if err != nil {
return nil, nil, trace.Wrap(err)
}

// Next, get list of all other available profiles. Filter out logged in
// profile if it exists and return a slice of *ProfileStatus.
files, err := ioutil.ReadDir(profileDir)
if err != nil {
return nil, nil, trace.Wrap(err)
}
for _, file := range files {
if file.IsDir() {
continue
}
if !strings.HasSuffix(file.Name(), ".yaml") {
continue
}
if file.Name() == profileName {
continue
}
ps, err := readProfile(profileDir, file.Name())
if err != nil {
return nil, nil, trace.Wrap(err)
}
others = append(others, ps)
}

return profile, others, nil
}

// LoadProfile populates Config with the values stored in the given
// profiles directory. If profileDir is an empty string, the default profile
// directory ~/.tsh is used.
Expand Down
15 changes: 7 additions & 8 deletions lib/client/keystore.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (
"os/user"
"path/filepath"
"strings"
"time"

"golang.org/x/crypto/ssh"

Expand Down Expand Up @@ -233,7 +232,6 @@ func (fs *FSLocalKeyStore) GetKey(proxyHost string, username string) (*Key, erro

key := &Key{Pub: pub, Priv: priv, Cert: cert, ProxyHost: proxyHost, TLSCert: tlsCert}

// expired certificate? this key won't be accepted anymore, lets delete it:
certExpiration, err := key.CertValidBefore()
if err != nil {
return nil, trace.Wrap(err)
Expand All @@ -242,12 +240,13 @@ func (fs *FSLocalKeyStore) GetKey(proxyHost string, username string) (*Key, erro
if err != nil {
return nil, trace.Wrap(err)
}
fs.log.Debugf("Returning certificate %q valid until %q, TLS certificate %q valid until %q", certFile, certExpiration, tlsCertFile, tlsCertExpiration)
if certExpiration.Before(time.Now()) || tlsCertExpiration.Before(time.Now()) {
fs.log.Infof("TTL expired (%v) or (%v) for session key %v", certExpiration, tlsCertExpiration, dirPath)
os.RemoveAll(dirPath)
return nil, trace.NotFound("session keys for %s are not found", proxyHost)
}

// TODO(russjones): Note, we may be returning expired certificates here, that
// is okay. If the certificates is expired, it's the responsibility of the
// TeleportClient to perform cleanup of the certificates and the profile.
fs.log.Debugf("Returning SSH certificate %q valid until %q, TLS certificate %q valid until %q",
certFile, certExpiration, tlsCertFile, tlsCertExpiration)

return key, nil
}

Expand Down
20 changes: 0 additions & 20 deletions lib/client/keystore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,26 +162,6 @@ func (s *KeyStoreTestSuite) TestDeleteAll(c *check.C) {
c.Assert(err, check.NotNil)
}

func (s *KeyStoreTestSuite) TestKeyExpiration(c *check.C) {
// make two keys: one is current, and the expire one
good := s.makeSignedKey(c, false)
good.ProxyHost = "good.host"
expired := s.makeSignedKey(c, true)
expired.ProxyHost = "expired.host"

s.store.AddKey("good.host", "sam", good)
s.store.AddKey("expired.host", "sam", expired)

// only "good" key should be returned
goodKey, err := s.store.GetKey("good.host", "sam")
c.Assert(err, check.IsNil)
c.Assert(goodKey, check.DeepEquals, good)

// expired key should not be returned
_, err = s.store.GetKey("expired.host", "sam")
c.Assert(err, check.NotNil)
}

func (s *KeyStoreTestSuite) TestKnownHosts(c *check.C) {
os.MkdirAll(s.store.KeyDir, 0777)
pub, _, _, _, err := ssh.ParseAuthorizedKey(CAPub)
Expand Down
56 changes: 56 additions & 0 deletions tool/tsh/tsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ func Run(args []string, underTest bool) {
show := app.Command("show", "Read an identity from file and print to stdout").Hidden()
show.Arg("identity_file", "The file containing a public key or a certificate").Required().StringVar(&cf.IdentityFileIn)

// The status command shows which proxy the user is logged into and metadata
// about the certificate.
status := app.Command("status", "Display the list of proxy servers and retrieved certificates")

// parse CLI commands+flags:
command, err := app.Parse(args)
if err != nil {
Expand Down Expand Up @@ -283,6 +287,8 @@ func Run(args []string, underTest bool) {
onLogout(&cf)
case show.FullCommand():
onShow(&cf)
case status.FullCommand():
onStatus(&cf)
}
}

Expand Down Expand Up @@ -820,3 +826,53 @@ func onShow(cf *CLIConf) {

fmt.Printf("Fingerprint: %s\n", ssh.FingerprintSHA256(pub))
}

// printStatus prints the status of the profile.
func printStatus(p *client.ProfileStatus, isActive bool) {
var prefix string
if isActive {
prefix = "> "
} else {
prefix = " "
}
duration := p.ValidUntil.Sub(time.Now())
humanDuration := "EXPIRED"
if duration.Nanoseconds() > 0 {
humanDuration = fmt.Sprintf("valid for %v", duration.Round(time.Minute))
}

fmt.Printf("%vProfile URL: %v\n", prefix, p.ProxyURL.String())
fmt.Printf(" Logged in as: %v\n", p.Username)
fmt.Printf(" Roles: %v*\n", strings.Join(p.Roles, ", "))
fmt.Printf(" Logins: %v\n", strings.Join(p.Logins, ", "))
fmt.Printf(" Valid until: %v [%v]\n", p.ValidUntil, humanDuration)
fmt.Printf(" Extensions: %v\n\n", strings.Join(p.Extensions, ", "))
}

// onStatus command shows which proxy the user is logged into and metadata
// about the certificate.
func onStatus(cf *CLIConf) {
// Get the status of the active profile ~/.tsh/profile as well as the status
// of any other proxies the user is logged into.
profile, profiles, err := client.Status("", cf.Proxy)
if err != nil {
utils.FatalError(err)
}

// Print the active profile.
if profile != nil {
printStatus(profile, true)
}

// Print all other profiles.
for _, p := range profiles {
printStatus(p, false)
}

// If we are printing profile, add a note that even though roles are listed
// here, they are only available in Enterprise.
if profile != nil || len(profiles) > 0 {
fmt.Printf("\n* RBAC is only available in Teleport Enterprise\n")
fmt.Printf(" https://gravitaitonal.com/teleport/docs/enteprise\n")
}
}

0 comments on commit 8e9f8ab

Please sign in to comment.