diff --git a/Project.toml b/Project.toml index 34077d6..35e7e20 100644 --- a/Project.toml +++ b/Project.toml @@ -8,6 +8,7 @@ CEnum = "fa961155-64e5-5f13-b03f-caf6b980ea82" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" FileWatching = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" +Kerberos_krb5_jll = "b39eb1a6-c29a-53d7-8c32-632cd16f18da" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" libssh_jll = "a8d4f100-aa25-5708-be18-96e0805c2c9d" @@ -17,6 +18,7 @@ CEnum = "0.4, 0.5" Dates = "1" DocStringExtensions = "0.9" FileWatching = "1" +Kerberos_krb5_jll = "1" Printf = "1" Sockets = "1" julia = "1.9" diff --git a/docs/src/changelog.md b/docs/src/changelog.md index da32dcf..8e250ef 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -7,6 +7,32 @@ CurrentModule = LibSSH This documents notable changes in LibSSH.jl. The format is based on [Keep a Changelog](https://keepachangelog.com). +## Unreleased + +### Added + +- It's possible to set an interface for the [`Forwarder`](@ref) socket to listen + on with the `localinterface` argument ([#6]). +- A new `Gssapi` module to help with [GSSAPI support](@ref). In particular, + [`Gssapi.principal_name()`](@ref) was added to get the name of the default + principal if one is available ([#6]). + +### Changed + +- The `userauth_*` functions will now throw a `LibSSHException` by default if + they got a `AuthStatus_Error` from libssh. This can be disabled by passing + `throw_on_error=false` ([#6]). +- `gssapi_available()` was renamed to [`Gssapi.isavailable()`](@ref) ([#6]). + +### Fixed + +- Fixed some race conditions in [`poll_loop()`](@ref) and +- [`Base.run(::Cmd, ::Session)`](@ref) now properly converts commands into + strings before executing them remotely, previously things like quotes weren't + escaped properly ([#6]). +- Fixed a bug in [`Base.run(::Cmd, ::Session)`](@ref) that would clear the + output buffer when printing ([#6]). + ## [v0.2.1] - 2024-02-27 ### Added diff --git a/docs/src/index.md b/docs/src/index.md index 2c1d85e..aa9607f 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,3 +1,7 @@ +```@meta +CurrentModule = LibSSH +``` + # LibSSH.jl This package provides a high-level API and low-level bindings to @@ -32,8 +36,8 @@ pkg> add LibSSH ## Limitations -- GSSAPI support is disabled on Windows and macOS due to `Kerberos_krb5_jll` not - being available on those platforms. +- GSSAPI support isn't available on all platforms (see + [`Gssapi.isavailable`](@ref)). - Many features don't have high-level wrappers (see [Contributing](@ref)). ## FAQ diff --git a/docs/src/utilities.md b/docs/src/utilities.md index dc66e04..430cbbf 100644 --- a/docs/src/utilities.md +++ b/docs/src/utilities.md @@ -21,7 +21,13 @@ Depth = 10 ```@docs lib_version get_hexa -gssapi_available +``` + +## GSSAPI support + +```@docs +Gssapi.isavailable +Gssapi.principal_name ``` ## Messages diff --git a/src/LibSSH.jl b/src/LibSSH.jl index e6db170..887ca95 100644 --- a/src/LibSSH.jl +++ b/src/LibSSH.jl @@ -158,15 +158,6 @@ function lib_version() VersionNumber(lib.LIBSSH_VERSION_MAJOR, lib.LIBSSH_VERSION_MINOR, lib.LIBSSH_VERSION_MICRO) end -""" -$(TYPEDSIGNATURES) - -Check if GSSAPI support is available (currently only Linux and FreeBSD). -""" -function gssapi_available() - Sys.islinux() || Sys.isfreebsd() -end - # Safe wrapper around poll_fd(). There's a race condition in older Julia # versions between the loop condition evaluation and this line, so we wrap # poll_fd() in a try-catch in case the bind (and thus the file descriptor) has @@ -185,6 +176,7 @@ function _safe_poll_fd(args...; kwargs...) return result end +include("gssapi.jl") include("pki.jl") include("callbacks.jl") include("session.jl") diff --git a/src/channel.jl b/src/channel.jl index c211657..c7b18e9 100644 --- a/src/channel.jl +++ b/src/channel.jl @@ -320,8 +320,10 @@ $(TYPEDSIGNATURES) Poll a (owning) channel in a loop while it's alive, which will trigger any callbacks. This function should always be called on a channel for it to work -properly. It will return the last result from [`lib.ssh_channel_poll()`](@ref), -which should be checked to see if it's `SSH_EOF`. +properly. It will return: +- `nothing` if the channel was closed during the loop. +- Otherwise the last result from [`lib.ssh_channel_poll()`](@ref), which should + be checked to see if it's `SSH_EOF`. """ function poll_loop(sshchan::SshChannel) if !sshchan.owning @@ -330,6 +332,13 @@ function poll_loop(sshchan::SshChannel) ret = SSH_ERROR while true + # We always check if the channel and session are open within the loop + # because ssh_channel_poll() will execute callbacks, which could close + # them before returning. + if !isopen(sshchan) + return nothing + end + # Note that we don't actually read any data in this loop, that's # handled by the callbacks, which are called by ssh_channel_poll(). ret = lib.ssh_channel_poll(sshchan.ptr, 0) @@ -339,6 +348,10 @@ function poll_loop(sshchan::SshChannel) break end + if !isopen(sshchan.session) + return nothing + end + wait(sshchan.session) end @@ -383,7 +396,7 @@ $(TYPEDFIELDS) This is analogous to `Base.Process`, it represents a command running over an SSH session. The stdout and stderr output are stored as byte arrays in `SshProcess.out` and `SshProcess.err` respectively. They can be converted to -strings using e.g. `String(process.out)`. +strings using e.g. `String(copy(process.out))`. """ @kwdef mutable struct SshProcess out::Vector{UInt8} = Vector{UInt8}() @@ -441,7 +454,7 @@ end function _exec_command(process::SshProcess) sshchan = process._sshchan session = sshchan.session - cmd_str = join(process.cmd.exec, " ") + cmd_str = Base.shell_escape(process.cmd) # Open the session channel ret = _session_trywait(session) do @@ -552,7 +565,7 @@ function Base.run(cmd::Cmd, session::Session; Base.wait(process._task) if print_out - print(String(process.out)) + print(String(copy(process.out))) end end @@ -611,8 +624,10 @@ function _on_client_channel_eof(session, sshchan, client) _logcb(client, "EOF") close(client.sshchan) - closewrite(client.sock) - close(client.sock) + if isopen(client.sock) + closewrite(client.sock) + close(client.sock) + end end function _on_client_channel_close(session, sshchan, client) @@ -715,9 +730,19 @@ mutable struct Forwarder Create a `Forwarder` object to forward data from `localport` to `remotehost:remoteport`. This will handle an internal [`SshChannel`](@ref) for forwarding. + + # Arguments + - `session`: The session to create a forwarding channel over. + - `localport`: The local port to bind to. + - `remotehost`: The remote host. + - `remoteport`: The remote port to bind to. + - `verbose`: Print logging messages on callbacks etc (not equivalent to + setting `log_verbosity` on a [`Session`](@ref)). + - `localinterface=IPv4(0)`: The interface to bind `localport` on. """ - function Forwarder(session::Session, localport::Int, remotehost::String, remoteport::Int; verbose=false) - listen_server = Sockets.listen(IPv4(0), localport) + function Forwarder(session::Session, localport::Int, remotehost::String, remoteport::Int; + verbose=false, localinterface::Sockets.IPAddr=IPv4(0)) + listen_server = Sockets.listen(localinterface, localport) self = new(remotehost, remoteport, localport, listen_server, nothing, _ForwardingClient[], diff --git a/src/gssapi.jl b/src/gssapi.jl new file mode 100644 index 0000000..9eb00c7 --- /dev/null +++ b/src/gssapi.jl @@ -0,0 +1,133 @@ +module Gssapi + +using DocStringExtensions +import Kerberos_krb5_jll: libgssapi_krb5 + +import ..LibSSH as ssh + + +const krb5_context = Ptr{Cvoid} +const krb5_ccache = Ptr{Cvoid} +const krb5_principal = Ptr{Cvoid} + +""" +$(TYPEDSIGNATURES) + +Check if GSSAPI support is available. Currently this is only available on Linux +and FreeBSD because it's difficult to cross-compile `Kerberos_krb5_jll` for +other platforms (which is what we depend on for GSSAPI). +""" +function isavailable() + Sys.islinux() || Sys.isfreebsd() +end + +mutable struct Krb5Context + ptr::Union{krb5_context, Nothing} + + function Krb5Context() + context_ref = Ref{krb5_context}() + ret = @ccall libgssapi_krb5.krb5_init_context(context_ref::Ptr{krb5_context})::Cint + if ret != 0 + error("Error initializing Kerberos context: $(ret)") + end + + self = new(context_ref[]) + finalizer(self) do context + @ccall libgssapi_krb5.krb5_free_context(context.ptr::krb5_context)::Cvoid + context.ptr = nothing + end + end +end + +mutable struct Krb5Ccache + ptr::Union{krb5_ccache, Nothing} + context::Krb5Context + + function Krb5Ccache(context::Krb5Context) + cache_ref = Ref{krb5_ccache}() + ret = @ccall libgssapi_krb5.krb5_cc_default(context.ptr::krb5_context, + cache_ref::Ptr{krb5_ccache})::Cint + if ret != 0 + error("Error initializing default Kerberos cache: $(ret)") + end + + self = new(cache_ref[], context) + finalizer(self) do cache + @ccall libgssapi_krb5.krb5_cc_close(cache.context.ptr::krb5_context, + cache.ptr::krb5_ccache)::Cint + cache.ptr = nothing + end + end +end + +mutable struct Krb5Principle + ptr::Union{krb5_principal, Nothing} + context::Krb5Context + + function Krb5Principle(context::Krb5Context, cache::Krb5Ccache) + principal_ref = Ref{krb5_principal}() + ret = @ccall libgssapi_krb5.krb5_cc_get_principal(context.ptr::krb5_context, + cache.ptr::krb5_ccache, + principal_ref::Ptr{krb5_principal})::Cint + if ret != 0 + error("Error retrieving default principal: $(ret)") + end + + self = new(principal_ref[], context) + finalizer(self) do principal + @ccall libgssapi_krb5.krb5_free_principal(principal.context.ptr::krb5_context, + principal.ptr::krb5_principal)::Cvoid + principal.ptr = nothing + end + end +end + +function krb5_unparse_name(context::Krb5Context, principal::Krb5Principle) + name_ref = Ref{Cstring}() + ret = @ccall libgssapi_krb5.krb5_unparse_name(context.ptr::krb5_context, + principal.ptr::krb5_principal, + name_ref::Ptr{Cstring})::Cint + if ret != 0 + error("Error getting principal name: $(ret)") + end + + name = unsafe_string(name_ref[]) + @ccall libgssapi_krb5.krb5_free_unparsed_name(context.ptr::krb5_context, + name_ref[]::Cstring)::Cvoid + + return name +end + +""" +$(TYPEDSIGNATURES) + +Returns the name of the default principal from the default credential cache, or +`nothing` if a principal with a valid ticket was not found. This can be used to +check if [`ssh.userauth_gssapi()`](@ref) can be called. Under the hood it uses: +- [`krb5_cc_default()`](https://web.mit.edu/kerberos/krb5-1.18/doc/appdev/refs/api/krb5_cc_default.html) +- [`krb5_cc_get_principal()`](https://web.mit.edu/kerberos/krb5-1.18/doc/appdev/refs/api/krb5_cc_get_principal.html) + +# Throws +- `ErrorException`: If GSSAPI support is not available on the current platform + (see [`isavailable()`](@ref)). +""" +function principal_name() + if !isavailable() + error("GSSAPI support not available, cannot get the principal name") + end + + context = Krb5Context() + cache = Krb5Ccache(context) + + # This will throw if a principal with a valid ticket doesn't exist + principal = nothing + try + principal = Krb5Principle(context, cache) + catch + return nothing + end + + return krb5_unparse_name(context, principal) +end + +end diff --git a/src/session.jl b/src/session.jl index ec3f73b..7bc3d7a 100644 --- a/src/session.jl +++ b/src/session.jl @@ -461,13 +461,18 @@ authentication method is always disabled in practice, but it's still useful to check which authentication methods the server supports (see [`userauth_list()`](@ref)). +# Arguments +- `session`: The session to authenticate. +- `throw_on_error=true`: Whether to throw if there's an internal error while + authenticating (`AuthStatus_Error`). + # Throws - `ArgumentError`: If the session isn't connected. -- `LibSSHException`: If there was an internal error. +- `LibSSHException`: If there was an internal error, unless `throw_on_error=false`. Wrapper around [`lib.ssh_userauth_none()`](@ref). """ -function userauth_none(session::Session) +function userauth_none(session::Session; throw_on_error=true) if !isconnected(session) throw(ArgumentError("Session is disconnected, cannot authenticate until it's connected")) end @@ -477,7 +482,7 @@ function userauth_none(session::Session) if ret == AuthStatus_Again wait(session) - elseif ret == AuthStatus_Error + elseif ret == AuthStatus_Error && throw_on_error throw(LibSSHException("Got AuthStatus_Error (SSH_AUTH_ERROR) when calling userauth_none()")) else return ret @@ -493,8 +498,6 @@ automatically call [`userauth_none()`](@ref) beforehand. # Throws - `ArgumentError`: If the session isn't connected. -- `LibSSHException`: If there was an internal error, or if the server by some - miracle actually supports `userauth_none`. Wrapper around [`lib.ssh_userauth_list()`](@ref). """ @@ -506,9 +509,6 @@ function userauth_list(session::Session) # First we have to call ssh_userauth_none() for... some reason, according to # the docs. status = userauth_none(session) - if status == AuthStatus_Success - throw(LibSSHException("userauth_none() succeeded when getting supported auth methods, this should not happen!")) - end ret = lib.ssh_userauth_list(session.ptr, C_NULL) auth_methods = AuthMethod[] @@ -527,14 +527,19 @@ $(TYPEDSIGNATURES) Authenticate by username and password. The username will be taken from `session.user`. +# Arguments +- `session`: The session to authenticate. +- `password`: The password to authenticate with. +- `throw_on_error=true`: Whether to throw_on_error if there's an internal error while + authenticating (`AuthStatus_Error`). + # Throws - `ArgumentError`: If the session isn't connected. -- `LibSSHException`: If there was an internal error, or if the server by some - miracle actually supports `userauth_none`. +- `LibSSHException`: If there was an internal error, unless `throw_on_error=false`. Wrapper around [`lib.ssh_userauth_password()`](@ref). """ -function userauth_password(session::Session, password::String) +function userauth_password(session::Session, password::String; throw_on_error=true) if !isconnected(session) throw(ArgumentError("Session is disconnected, cannot authenticate until it's connected")) end @@ -547,7 +552,7 @@ function userauth_password(session::Session, password::String) if ret == AuthStatus_Again wait(session) - elseif ret == AuthStatus_Error + elseif ret == AuthStatus_Error && throw_on_error throw(LibSSHException("Got AuthStatus_Error (SSH_AUTH_ERROR) when authenticating")) else return ret @@ -559,26 +564,37 @@ end $(TYPEDSIGNATURES) Authenticate with GSSAPI. This is not available on all platforms (see -[`gssapi_available`](@ref)). +[`Gssapi.isavailable()`](@ref)). + +# Arguments +- `session`: The session to authenticate. +- `throw_on_error=true`: Whether to throw if there's an internal error while + authenticating (`AuthStatus_Error`). # Throws - `ArgumentError`: If the session isn't connected. - `ErrorException`: If GSSAPI support isn't available. +- `LibSSHException`: If there was an internal error, unless `throw_on_error=false`. Wrapper around [`lib.ssh_userauth_gssapi()`](@ref). """ -function userauth_gssapi(session::Session) +function userauth_gssapi(session::Session; throw_on_error=true) if !isconnected(session) throw(ArgumentError("Session is disconnected, cannot authenticate until it's connected")) - elseif !gssapi_available() + elseif !Gssapi.isavailable() error("GSSAPI support is not available") end ret = _session_trywait(session) do lib.ssh_userauth_gssapi(session.ptr) end + status = AuthStatus(ret) + + if status == AuthStatus_Error && throw_on_error + throw(LibSSHException("Got AuthStatus_Error (SSH_AUTH_ERROR) when authenticating")) + end - return AuthStatus(ret) + return status end """ @@ -586,12 +602,18 @@ $(TYPEDSIGNATURES) Attempt to authenticate with the keyboard-interactive method. +# Arguments +- `session`: The session to authenticate. +- `throw_on_error=true`: Whether to throw if there's an internal error while + authenticating (`AuthStatus_Error`). + # Throws - `ArgumentError`: If the session isn't connected. +- `LibSSHException`: If there was an internal error, unless `throw_on_error=false`. Wrapper around [`lib.ssh_userauth_kbdint`](@ref). """ -function userauth_kbdint(session::Session) +function userauth_kbdint(session::Session; throw_on_error=true) if !isconnected(session) throw(ArgumentError("Session is disconnected, cannot authenticate until it's connected")) end @@ -599,8 +621,13 @@ function userauth_kbdint(session::Session) ret = _session_trywait(session) do lib.ssh_userauth_kbdint(session.ptr, C_NULL, C_NULL) end + status = AuthStatus(ret) + + if status == AuthStatus_Error && throw_on_error + throw(LibSSHException("Got AuthStatus_Error (SSH_AUTH_ERROR) when authenticating")) + end - return AuthStatus(ret) + return status end """ @@ -638,8 +665,14 @@ $(TYPEDSIGNATURES) Sets answers for a keyboard-interactive auth session. Uses [`lib.ssh_userauth_kbdint_setanswer`](@ref) internally. +# Arguments +- `session`: The session to authenticate. +- `answers`: A vector of answers for each prompt sent by the server. + # Throws -- `ArgumentError`: If the session isn't connected. +- `ArgumentError`: If the session isn't connected, or if the wrong number of + answers were passed. +- `LibSSHException`: If setting the answers failed. """ function userauth_kbdint_setanswers(session::Session, answers::Vector{String}) if !isconnected(session) @@ -655,7 +688,7 @@ function userauth_kbdint_setanswers(session::Session, answers::Vector{String}) ret = lib.ssh_userauth_kbdint_setanswer(session.ptr, i - 1, Base.cconvert(Cstring, answer)) if ret != SSH_OK - throw("Error while setting answer $(i) with ssh_userauth_kbdint_setanswer(): $(ret)") + throw(LibSSHException("Error while setting answer $(i) with ssh_userauth_kbdint_setanswer(): $(ret)")) end end end diff --git a/test/LibSSHTests.jl b/test/LibSSHTests.jl index ae63ec8..10a3a4f 100644 --- a/test/LibSSHTests.jl +++ b/test/LibSSHTests.jl @@ -357,6 +357,9 @@ end # Test Base methods @test read(`echo foo`, session, String) == "foo\n" @test success(`whoami`, session) + + # Check that commands with quotes are properly escaped + @test read(`echo 'foo bar'`, session, String) == "foo bar\n" end end @@ -415,6 +418,18 @@ end @test replace(ssh.get_hexa(sha256_hash), ":" => "") == bytes2hex(sha256_hash) end +@testset "GSSAPI" begin + @test ssh.Gssapi.isavailable() isa Bool + + # Sadly this is quite lightly tested since it's nontrivial to set up a + # Kerberos instance and acquire a token etc. + if ssh.Gssapi.isavailable() + @test ssh.Gssapi.principal_name() isa Union{String, Nothing} + else + @test_throws ErrorException ssh.Gssapi.principal_name() + end +end + @testset "Examples" begin mktempdir() do tempdir # Test and generate the examples @@ -430,7 +445,6 @@ end @testset "Utility functions" begin @test ssh.lib_version() isa VersionNumber - @test ssh.gssapi_available() isa Bool end @testset "Aqua.jl" begin