Skip to content

Commit 63a47f1

Browse files
committed
Support more flexible naming of host config file and resource (groups)
1 parent 57854ff commit 63a47f1

File tree

3 files changed

+83
-33
lines changed

3 files changed

+83
-33
lines changed

README.markdown

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,27 @@ It provides all the details required for properly establishing an SSH connection
6262
If you don't provide an explicit `HostConfigProvider` the default one will be used. For every hostname you pass to the
6363
`SSH.apply` method this default `HostConfigProvider` expects a file `~/.scala-ssh/{hostname}`, which contains the
6464
properties of a `HostConfig` in a simple config file format (see below for details). The `HostResourceConfig` object
65-
gives you alternative `HostConfigProvider` implementations that read the host config from JAR resources.
65+
gives you alternative `HostConfigProvider` implementations that read the host config from classpath resources.
66+
67+
If the file `~/.scala-ssh/{hostname}` (or the classpath resource `{hostname}`) doesn't exist _scala-ssh_ looks for more
68+
general files (or resources) in the following way:
69+
70+
1. As long as the first segment of the host name (up to the first `.`) contains one or more digits replace the rightmost
71+
of these with `X` and look for a respectively named file or resource. Repeat until no digits left.
72+
2. Drop all characters up to (and including) the first `.` from the host name and look for a respectively named file or
73+
resource.
74+
3. Repeat from 1. as long as there are characters left.
75+
76+
This means that for a host with name `node42.tier1.example.com` the following locations (either under `~/.scala-ssh/`
77+
or the classpath, depending on the `HostConfigProvider`) are tried:
78+
79+
1. `node42.tier1.example.com`
80+
2. `node4X.tier1.example.com`
81+
3. `nodeXX.tier1.example.com`
82+
4. `tier1.example.com`
83+
5. `tierX.example.com`
84+
6. `example.com`
85+
7. `com`
6686

6787

6888
## Host Config File Format

src/main/scala/com/decodified/scalassh/HostConfig.scala

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import HostKeyVerifiers._
2323
import java.security.PublicKey
2424
import net.schmizz.sshj.common.SecurityUtils
2525
import net.schmizz.sshj.transport.verification.{OpenSSHKnownHosts, HostKeyVerifier}
26+
import annotation.tailrec
2627

2728
trait HostConfigProvider extends (String => Validated[HostConfig])
2829

@@ -156,34 +157,49 @@ object HostFileConfig {
156157
def apply(): HostConfigProvider = apply(DefaultHostFileDir)
157158
def apply(hostFilesDir: String): HostConfigProvider = new FromStringsHostConfigProvider {
158159
def rawLines(host: String) = {
159-
val hostFile = new File(hostFilesDir + File.separator + host)
160-
try {
161-
Either.cond(
162-
hostFile.exists,
163-
hostFile.getAbsolutePath -> Source.fromFile(hostFile, "utf8").getLines(),
164-
"Host file '%s' not found, either provide one or use a concrete HostConfig, PasswordLogin or PublicKeyLogin".format(hostFile)
165-
)
166-
} catch {
167-
case e: IOException => Left("Could not read host file '%' due to %s".format(hostFile, e))
160+
val locations = searchLocations(host).map(name => new File(hostFilesDir + File.separator + name))
161+
locations.find(_.exists) match {
162+
case Some(file) =>
163+
try Right(file.getAbsolutePath -> Source.fromFile(file, "utf8").getLines())
164+
catch { case e: IOException => Left("Could not read host file '%' due to %s".format(file, e)) }
165+
case None =>
166+
Left(("Host files '%s' not found, either provide one or use a concrete HostConfig, PasswordLogin or " +
167+
"PublicKeyLogin").format(locations.mkString("', '")))
168168
}
169169
}
170170
}
171+
172+
def searchLocations(name: String): Stream[String] = {
173+
if (name.isEmpty) Stream.empty
174+
else name #:: {
175+
val dotIx = name.indexOf('.')
176+
@tailrec def findDigit(i: Int): Int = if (i < 0 || name.charAt(i).isDigit) i else findDigit(i - 1)
177+
val digitIx = findDigit(if (dotIx > 0) dotIx - 1 else name.length - 1)
178+
if (digitIx >= 0 && digitIx < dotIx)
179+
searchLocations(name.updated(digitIx, 'X'))
180+
else if (dotIx > 0)
181+
searchLocations(name.substring(dotIx + 1))
182+
else Stream.empty
183+
}
184+
}
171185
}
172186

173187
object HostResourceConfig {
174188
def apply(): HostConfigProvider = apply("")
175189
def apply(resourceBase: String): HostConfigProvider = new FromStringsHostConfigProvider {
176190
def rawLines(host: String) = {
177-
val hostResource = resourceBase + host
178-
try {
179-
val resourceStream = getClass.getClassLoader.getResourceAsStream(hostResource)
180-
Either.cond(
181-
resourceStream != null,
182-
hostResource -> Source.fromInputStream(resourceStream, "utf8").getLines(),
183-
"Host resource '%s' not found".format(hostResource)
184-
)
185-
} catch {
186-
case e: IOException => Left("Could not read host resource '%' due to %s".format(hostResource, e))
191+
val locations = HostFileConfig.searchLocations(host).map(resourceBase + _)
192+
locations.map { r =>
193+
r -> {
194+
val inputStream = getClass.getClassLoader.getResourceAsStream(r)
195+
try new StreamCopier().emptyToString(inputStream).split("\n").toList
196+
catch { case _: Exception => null }
197+
}
198+
}.find(_._2 != null) match {
199+
case Some(result) => Right(result)
200+
case None =>
201+
Left(("Host resources '%s' not found, either provide one or use a concrete HostConfig, PasswordLogin or " +
202+
"PublicKeyLogin").format(locations.mkString("', '")))
187203
}
188204
}
189205
}

src/test/scala/com/decodified/scalassh/HostFileConfigSpec.scala

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,46 @@
1616

1717
package com.decodified.scalassh
1818

19-
import org.specs2.Specification
19+
import org.specs2.mutable.Specification
2020

21-
class HostFileConfigSpec extends Specification { def is =
21+
class HostFileConfigSpec extends Specification {
2222

23-
"Depending on the host file the HostFileConfig should produce a proper" ^
23+
"Depending on the host file the HostFileConfig should produce a proper" >> {
2424
"PasswordLogin" ! {
25-
config("password.com") mustEqual Right(HostConfig(PasswordLogin("bob", "123"), "password.com", enableCompression = true))
26-
} ^
25+
config("password.com") === Right(HostConfig(PasswordLogin("bob", "123"), "password.com", enableCompression = true))
26+
}
2727
"unencrypted PublicKeyLogin" ! {
28-
config("keyfile.com") mustEqual Right(HostConfig(PublicKeyLogin("alice", "/some/file"), "xyz.special.com", port = 30))
29-
}^
28+
config("keyfile.com") === Right(HostConfig(PublicKeyLogin("alice", "/some/file"), "xyz.special.com", port = 30))
29+
}
3030
"encrypted PublicKeyLogin" ! {
31-
config("enc-keyfile.com") mustEqual Right(HostConfig(PublicKeyLogin("alice", "superSecure", "/some/file" :: Nil), "enc-keyfile.com"))
32-
} ^
31+
config("enc-keyfile.com") === Right(HostConfig(PublicKeyLogin("alice", "superSecure", "/some/file" :: Nil), "enc-keyfile.com"))
32+
}
3333
"error message if the file is missing" ! {
34-
config("non-existing.com").left.get mustEqual "Host resource 'non-existing.com' not found"
35-
} ^
34+
config("non-existing.com").left.get === "Host resources 'non-existing.com', 'com' not found, either " +
35+
"provide one or use a concrete HostConfig, PasswordLogin or PublicKeyLogin"
36+
}
3637
"error message if the login-type is invalid" ! {
3738
config("invalid-login-type.com").left.get must startingWith("Illegal login-type setting 'fancy pants'")
38-
} ^
39+
}
3940
"error message if the username is missing" ! {
4041
config("missing-user.com").left.get must endWith("is missing required setting 'username'")
41-
} ^
42+
}
4243
"error message if the host file contains an illegal line" ! {
4344
config("illegal-line.com").left.get must endWith("contains illegal line:\nthis line triggers an error!")
4445
}
46+
}
47+
48+
"The sequence of searched config locations for host `node42.tier1.example.com`" should
49+
"be as described in the README" ! {
50+
HostFileConfig.searchLocations("node42.tier1.example.com").toList ===
51+
"node42.tier1.example.com" ::
52+
"node4X.tier1.example.com" ::
53+
"nodeXX.tier1.example.com" ::
54+
"tier1.example.com" ::
55+
"tierX.example.com" ::
56+
"example.com" ::
57+
"com" :: Nil
58+
}
4559

4660
val config = HostResourceConfig()
4761
}

0 commit comments

Comments
 (0)