Commit 3b825b62 authored by Nick Thomas's avatar Nick Thomas

Merge branch 'fj-replace-terminal-with-channel' into 'master'

Replace terminal terminology to channel

See merge request gitlab-org/gitlab-workhorse!382
parents 17ec2e0e 57940239
...@@ -29,7 +29,50 @@ type connWithReq struct { ...@@ -29,7 +29,50 @@ type connWithReq struct {
req *http.Request req *http.Request
} }
func TestTerminalHappyPath(t *testing.T) { func TestChannelHappyPath(t *testing.T) {
tests := []struct {
name string
channelPath string
}{
{"environments", envTerminalPath},
{"jobs", jobTerminalPath},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
serverConns, clientURL, close := wireupChannel(test.channelPath, nil, "channel.k8s.io")
defer close()
client, _, err := dialWebsocket(clientURL, nil, "terminal.gitlab.com")
if err != nil {
t.Fatal(err)
}
server := (<-serverConns).conn
defer server.Close()
message := "test message"
// channel.k8s.io: server writes to channel 1, STDOUT
if err := say(server, "\x01"+message); err != nil {
t.Fatal(err)
}
assertReadMessage(t, client, websocket.BinaryMessage, message)
if err := say(client, message); err != nil {
t.Fatal(err)
}
// channel.k8s.io: client writes get put on channel 0, STDIN
assertReadMessage(t, server, websocket.BinaryMessage, "\x00"+message)
// Closing the client should send an EOT signal to the server's STDIN
client.Close()
assertReadMessage(t, server, websocket.BinaryMessage, "\x00\x04")
})
}
}
func TestChannelHappyPathWithTerminalResponse(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
terminalPath string terminalPath string
...@@ -72,8 +115,8 @@ func TestTerminalHappyPath(t *testing.T) { ...@@ -72,8 +115,8 @@ func TestTerminalHappyPath(t *testing.T) {
} }
} }
func TestTerminalBadTLS(t *testing.T) { func TestChannelBadTLS(t *testing.T) {
_, clientURL, close := wireupTerminal(envTerminalPath, badCA, "channel.k8s.io") _, clientURL, close := wireupChannel(envTerminalPath, badCA, "channel.k8s.io")
defer close() defer close()
client, _, err := dialWebsocket(clientURL, nil, "terminal.gitlab.com") client, _, err := dialWebsocket(clientURL, nil, "terminal.gitlab.com")
...@@ -86,8 +129,8 @@ func TestTerminalBadTLS(t *testing.T) { ...@@ -86,8 +129,8 @@ func TestTerminalBadTLS(t *testing.T) {
} }
} }
func TestTerminalSessionTimeout(t *testing.T) { func TestChannelSessionTimeout(t *testing.T) {
serverConns, clientURL, close := wireupTerminal(envTerminalPath, timeout, "channel.k8s.io") serverConns, clientURL, close := wireupChannel(envTerminalPath, timeout, "channel.k8s.io")
defer close() defer close()
client, _, err := dialWebsocket(clientURL, nil, "terminal.gitlab.com") client, _, err := dialWebsocket(clientURL, nil, "terminal.gitlab.com")
...@@ -106,10 +149,10 @@ func TestTerminalSessionTimeout(t *testing.T) { ...@@ -106,10 +149,10 @@ func TestTerminalSessionTimeout(t *testing.T) {
} }
} }
func TestTerminalProxyForwardsHeadersFromUpstream(t *testing.T) { func TestChannelProxyForwardsHeadersFromUpstream(t *testing.T) {
hdr := make(http.Header) hdr := make(http.Header)
hdr.Set("Random-Header", "Value") hdr.Set("Random-Header", "Value")
serverConns, clientURL, close := wireupTerminal(envTerminalPath, setHeader(hdr), "channel.k8s.io") serverConns, clientURL, close := wireupChannel(envTerminalPath, setHeader(hdr), "channel.k8s.io")
defer close() defer close()
client, _, err := dialWebsocket(clientURL, nil, "terminal.gitlab.com") client, _, err := dialWebsocket(clientURL, nil, "terminal.gitlab.com")
...@@ -125,8 +168,8 @@ func TestTerminalProxyForwardsHeadersFromUpstream(t *testing.T) { ...@@ -125,8 +168,8 @@ func TestTerminalProxyForwardsHeadersFromUpstream(t *testing.T) {
} }
} }
func TestTerminalProxyForwardsXForwardedForFromClient(t *testing.T) { func TestChannelProxyForwardsXForwardedForFromClient(t *testing.T) {
serverConns, clientURL, close := wireupTerminal(envTerminalPath, nil, "channel.k8s.io") serverConns, clientURL, close := wireupChannel(envTerminalPath, nil, "channel.k8s.io")
defer close() defer close()
hdr := make(http.Header) hdr := make(http.Header)
...@@ -149,6 +192,22 @@ func TestTerminalProxyForwardsXForwardedForFromClient(t *testing.T) { ...@@ -149,6 +192,22 @@ func TestTerminalProxyForwardsXForwardedForFromClient(t *testing.T) {
} }
} }
func wireupChannel(channelPath string, modifier func(*api.Response), subprotocols ...string) (chan connWithReq, string, func()) {
serverConns, remote := startWebsocketServer(subprotocols...)
authResponse := channelOkBody(remote, nil, subprotocols...)
if modifier != nil {
modifier(authResponse)
}
upstream := testAuthServer(nil, 200, authResponse)
workhorse := startWorkhorseServer(upstream.URL)
return serverConns, websocketURL(workhorse.URL, channelPath), func() {
workhorse.Close()
upstream.Close()
remote.Close()
}
}
func wireupTerminal(terminalPath string, modifier func(*api.Response), subprotocols ...string) (chan connWithReq, string, func()) { func wireupTerminal(terminalPath string, modifier func(*api.Response), subprotocols ...string) (chan connWithReq, string, func()) {
serverConns, remote := startWebsocketServer(subprotocols...) serverConns, remote := startWebsocketServer(subprotocols...)
authResponse := terminalOkBody(remote, nil, subprotocols...) authResponse := terminalOkBody(remote, nil, subprotocols...)
...@@ -183,6 +242,25 @@ func startWebsocketServer(subprotocols ...string) (chan connWithReq, *httptest.S ...@@ -183,6 +242,25 @@ func startWebsocketServer(subprotocols ...string) (chan connWithReq, *httptest.S
return connCh, server return connCh, server
} }
func channelOkBody(remote *httptest.Server, header http.Header, subprotocols ...string) *api.Response {
out := &api.Response{
Channel: &api.ChannelSettings{
Url: websocketURL(remote.URL),
Header: header,
Subprotocols: subprotocols,
MaxSessionTime: 0,
},
}
if len(remote.TLS.Certificates) > 0 {
data := bytes.NewBuffer(nil)
pem.Encode(data, &pem.Block{Type: "CERTIFICATE", Bytes: remote.TLS.Certificates[0].Certificate[0]})
out.Channel.CAPem = data.String()
}
return out
}
func terminalOkBody(remote *httptest.Server, header http.Header, subprotocols ...string) *api.Response { func terminalOkBody(remote *httptest.Server, header http.Header, subprotocols ...string) *api.Response {
out := &api.Response{ out := &api.Response{
Terminal: &api.TerminalSettings{ Terminal: &api.TerminalSettings{
...@@ -203,16 +281,16 @@ func terminalOkBody(remote *httptest.Server, header http.Header, subprotocols .. ...@@ -203,16 +281,16 @@ func terminalOkBody(remote *httptest.Server, header http.Header, subprotocols ..
} }
func badCA(authResponse *api.Response) { func badCA(authResponse *api.Response) {
authResponse.Terminal.CAPem = "Bad CA" authResponse.Channel.CAPem = "Bad CA"
} }
func timeout(authResponse *api.Response) { func timeout(authResponse *api.Response) {
authResponse.Terminal.MaxSessionTime = 1 authResponse.Channel.MaxSessionTime = 1
} }
func setHeader(hdr http.Header) func(*api.Response) { func setHeader(hdr http.Header) func(*api.Response) {
return func(authResponse *api.Response) { return func(authResponse *api.Response) {
authResponse.Terminal.Header = hdr authResponse.Channel.Header = hdr
} }
} }
......
# Terminal support # Websocket channel support
In some cases, GitLab can provide in-browser terminal access to an In some cases, GitLab can provide in-browser terminal access to an
environment (which is a running server or container, onto which a environment (which is a running server or container, onto which a
project has been deployed) through a WebSocket. Workhorse manages project has been deployed), or even access to services running in CI
the WebSocket upgrade and long-lived connection to the terminal for through a WebSocket. Workhorse manages the WebSocket upgrade and
the environment, which frees up GitLab to process other requests. long-lived connection to the websocket connection, which frees
up GitLab to process other requests.
This document outlines the architecture of these connections. This document outlines the architecture of these connections.
...@@ -47,8 +48,9 @@ UTF-8 strings, in addition to any subprotocol expectations. ...@@ -47,8 +48,9 @@ UTF-8 strings, in addition to any subprotocol expectations.
## Browser to Workhorse ## Browser to Workhorse
GitLab serves a JavaScript terminal emulator to the browser on Using the terminal as an example, GitLab serves a JavaScript terminal
a URL like `https://gitlab.com/group/project/environments/1/terminal`. emulator to the browser on a URL like
`https://gitlab.com/group/project/environments/1/terminal`.
This opens a websocket connection to, e.g., This opens a websocket connection to, e.g.,
`wss://gitlab.com/group/project/environments/1/terminal.ws`, `wss://gitlab.com/group/project/environments/1/terminal.ws`,
This endpoint doesn't exist in GitLab - only in Workhorse. This endpoint doesn't exist in GitLab - only in Workhorse.
...@@ -80,12 +82,12 @@ Control frames, such as `PingMessage` or `CloseMessage`, have ...@@ -80,12 +82,12 @@ Control frames, such as `PingMessage` or `CloseMessage`, have
their usual meanings. their usual meanings.
`BinaryMessage` frames sent from the browser to the server are `BinaryMessage` frames sent from the browser to the server are
arbitrary terminal input. arbitrary text input.
`BinaryMessage` frames sent from the server to the browser are `BinaryMessage` frames sent from the server to the browser are
arbitrary terminal output. arbitrary text output.
These frames are expected to contain ANSI terminal control codes These frames are expected to contain ANSI text control codes
and may be in any encoding. and may be in any encoding.
### `base64.terminal.gitlab.com` ### `base64.terminal.gitlab.com`
...@@ -95,11 +97,11 @@ Control frames, such as `PingMessage` or `CloseMessage`, have ...@@ -95,11 +97,11 @@ Control frames, such as `PingMessage` or `CloseMessage`, have
their usual meanings. their usual meanings.
`TextMessage` frames sent from the browser to the server are `TextMessage` frames sent from the browser to the server are
base64-encoded arbitrary terminal input (so the server must base64-encoded arbitrary text input (so the server must
base64-decode them before inputting them). base64-decode them before inputting them).
`TextMessage` frames sent from the server to the browser are `TextMessage` frames sent from the server to the browser are
base64-encoded arbitrary terminal output (so the browser must base64-encoded arbitrary text output (so the browser must
base64-decode them before outputting them). base64-decode them before outputting them).
In their base64-encoded form, these frames are expected to In their base64-encoded form, these frames are expected to
...@@ -107,8 +109,8 @@ contain ANSI terminal control codes, and may be in any encoding. ...@@ -107,8 +109,8 @@ contain ANSI terminal control codes, and may be in any encoding.
## Workhorse to GitLab ## Workhorse to GitLab
Before upgrading the browser, Workhorse sends a normal HTTP Using again the terminal as an example, before upgrading the browser,
request to GitLab on a URL like Workhorse sends a normal HTTP request to GitLab on a URL like
`https://gitlab.com/group/project/environments/1/terminal.ws/authorize`. `https://gitlab.com/group/project/environments/1/terminal.ws/authorize`.
This returns a JSON response containing details of where the This returns a JSON response containing details of where the
terminal can be found, and how to connect it. In particular, terminal can be found, and how to connect it. In particular,
...@@ -123,11 +125,11 @@ Workhorse periodically re-checks this endpoint, and if it gets an ...@@ -123,11 +125,11 @@ Workhorse periodically re-checks this endpoint, and if it gets an
error response, or the details of the terminal change, it will error response, or the details of the terminal change, it will
terminate the websocket session. terminate the websocket session.
## Workhorse to Terminal ## Workhorse to the WebSocket server
In GitLab, environments may have a deployment service (e.g., In GitLab, environments or CI jobs may have a deployment service (e.g.,
`KubernetesService`) associated with them. This service knows `KubernetesService`) associated with them. This service knows
where the terminals for an environment may be found, and these where the terminals or the service for an environment may be found, and these
details are returned to Workhorse by GitLab. details are returned to Workhorse by GitLab.
These URLs are *also* WebSocket URLs, and GitLab tells Workhorse These URLs are *also* WebSocket URLs, and GitLab tells Workhorse
...@@ -143,19 +145,19 @@ also upgraded. ...@@ -143,19 +145,19 @@ also upgraded.
Workhorse now has two websocket connections, albeit with Workhorse now has two websocket connections, albeit with
differing subprotocols. It decodes incoming frames from the differing subprotocols. It decodes incoming frames from the
browser, re-encodes them to the terminal's subprotocol, and browser, re-encodes them to the the channel's subprotocol, and
sends them to the terminal. Similarly, it decodes incoming sends them to the channel. Similarly, it decodes incoming
frames from the terminal, re-encodes them to the browser's frames from the channel, re-encodes them to the browser's
subprotocol, and sends them to the browser. subprotocol, and sends them to the browser.
When either connection closes or enters an error state, When either connection closes or enters an error state,
Workhorse detects the error and closes the other connection, Workhorse detects the error and closes the other connection,
terminating the terminal session. If the browser is the terminating the channel session. If the browser is the
connection that has disconnected, Workhorse will send an ANSI connection that has disconnected, Workhorse will send an ANSI
`End of Transmission` control code (the `0x04` byte) to the `End of Transmission` control code (the `0x04` byte) to the
terminal, encoded according to the appropriate subprotocol. channel, encoded according to the appropriate subprotocol.
Workhorse will automatically reply to any websocket ping frame Workhorse will automatically reply to any websocket ping frame
sent by the terminal, to avoid being disconnected. sent by the channel, to avoid being disconnected.
Currently, Workhorse only supports the following subprotocols. Currently, Workhorse only supports the following subprotocols.
Supporting new deployment services will require new subprotocols Supporting new deployment services will require new subprotocols
......
...@@ -123,7 +123,10 @@ type Response struct { ...@@ -123,7 +123,10 @@ type Response struct {
Archive string `json:"archive"` Archive string `json:"archive"`
// Entry is a filename inside the archive point to file that needs to be extracted // Entry is a filename inside the archive point to file that needs to be extracted
Entry string `json:"entry"` Entry string `json:"entry"`
// Used to communicate terminal session details // Used to communicate channel session details
Channel *ChannelSettings
// Used to communicate terminal session details (Deprecated)
// Issue to remove this field https://gitlab.com/gitlab-org/gitlab-workhorse/issues/214
Terminal *TerminalSettings Terminal *TerminalSettings
// GitalyServer specifies an address and authentication token for a gitaly server we should connect to. // GitalyServer specifies an address and authentication token for a gitaly server we should connect to.
GitalyServer gitaly.Server GitalyServer gitaly.Server
......
package api
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/helper"
)
type ChannelSettings struct {
// The channel provider may require use of a particular subprotocol. If so,
// it must be specified here, and Workhorse must have a matching codec.
Subprotocols []string
// The websocket URL to connect to.
Url string
// Any headers (e.g., Authorization) to send with the websocket request
Header http.Header
// The CA roots to validate the remote endpoint with, for wss:// URLs. The
// system-provided CA pool will be used if this is blank. PEM-encoded data.
CAPem string
// The value is specified in seconds. It is converted to time.Duration
// later.
MaxSessionTime int
}
func (t *ChannelSettings) URL() (*url.URL, error) {
return url.Parse(t.Url)
}
func (t *ChannelSettings) Dialer() *websocket.Dialer {
dialer := &websocket.Dialer{
Subprotocols: t.Subprotocols,
}
if len(t.CAPem) > 0 {
pool := x509.NewCertPool()
pool.AppendCertsFromPEM([]byte(t.CAPem))
dialer.TLSClientConfig = &tls.Config{RootCAs: pool}
}
return dialer
}
func (t *ChannelSettings) Clone() *ChannelSettings {
// Doesn't clone the strings, but that's OK as strings are immutable in go
cloned := *t
cloned.Header = helper.HeaderClone(t.Header)
return &cloned
}
func (t *ChannelSettings) Dial() (*websocket.Conn, *http.Response, error) {
return t.Dialer().Dial(t.Url, t.Header)
}
func (t *ChannelSettings) Validate() error {
if t == nil {
return fmt.Errorf("channel details not specified")
}
if len(t.Subprotocols) == 0 {
return fmt.Errorf("no subprotocol specified")
}
parsedURL, err := t.URL()
if err != nil {
return fmt.Errorf("invalid URL")
}
if parsedURL.Scheme != "ws" && parsedURL.Scheme != "wss" {
return fmt.Errorf("invalid websocket scheme: %q", parsedURL.Scheme)
}
return nil
}
func (t *ChannelSettings) IsEqual(other *ChannelSettings) bool {
if t == nil && other == nil {
return true
}
if t == nil || other == nil {
return false
}
if len(t.Subprotocols) != len(other.Subprotocols) {
return false
}
for i, subprotocol := range t.Subprotocols {
if other.Subprotocols[i] != subprotocol {
return false
}
}
if len(t.Header) != len(other.Header) {
return false
}
for header, values := range t.Header {
if len(values) != len(other.Header[header]) {
return false
}
for i, value := range values {
if other.Header[header][i] != value {
return false
}
}
}
return t.Url == other.Url &&
t.CAPem == other.CAPem &&
t.MaxSessionTime == other.MaxSessionTime
}
...@@ -5,47 +5,47 @@ import ( ...@@ -5,47 +5,47 @@ import (
"testing" "testing"
) )
func terminal(url string, subprotocols ...string) *TerminalSettings { func channel(url string, subprotocols ...string) *ChannelSettings {
return &TerminalSettings{ return &ChannelSettings{
Url: url, Url: url,
Subprotocols: subprotocols, Subprotocols: subprotocols,
MaxSessionTime: 0, MaxSessionTime: 0,
} }
} }
func ca(term *TerminalSettings) *TerminalSettings { func ca(channel *ChannelSettings) *ChannelSettings {
term = term.Clone() channel = channel.Clone()
term.CAPem = "Valid CA data" channel.CAPem = "Valid CA data"
return term return channel
} }
func timeout(term *TerminalSettings) *TerminalSettings { func timeout(channel *ChannelSettings) *ChannelSettings {
term = term.Clone() channel = channel.Clone()
term.MaxSessionTime = 600 channel.MaxSessionTime = 600
return term return channel
} }
func header(term *TerminalSettings, values ...string) *TerminalSettings { func header(channel *ChannelSettings, values ...string) *ChannelSettings {
if len(values) == 0 { if len(values) == 0 {
values = []string{"Dummy Value"} values = []string{"Dummy Value"}
} }
term = term.Clone() channel = channel.Clone()
term.Header = http.Header{ channel.Header = http.Header{
"Header": values, "Header": values,
} }
return term return channel
} }
func TestClone(t *testing.T) { func TestClone(t *testing.T) {
a := ca(header(terminal("ws:", "", ""))) a := ca(header(channel("ws:", "", "")))
b := a.Clone() b := a.Clone()
if a == b { if a == b {
t.Fatalf("Address of cloned terminal didn't change") t.Fatalf("Address of cloned channel didn't change")
} }
if &a.Subprotocols == &b.Subprotocols { if &a.Subprotocols == &b.Subprotocols {
...@@ -59,40 +59,40 @@ func TestClone(t *testing.T) { ...@@ -59,40 +59,40 @@ func TestClone(t *testing.T) {
func TestValidate(t *testing.T) { func TestValidate(t *testing.T) {
for i, tc := range []struct { for i, tc := range []struct {
terminal *TerminalSettings channel *ChannelSettings
valid bool valid bool
msg string msg string
}{ }{
{nil, false, "nil terminal"}, {nil, false, "nil channel"},
{terminal("", ""), false, "empty URL"}, {channel("", ""), false, "empty URL"},
{terminal("ws:"), false, "empty subprotocols"}, {channel("ws:"), false, "empty subprotocols"},
{terminal("ws:", "foo"), true, "any subprotocol"}, {channel("ws:", "foo"), true, "any subprotocol"},
{terminal("ws:", "foo", "bar"), true, "multiple subprotocols"}, {channel("ws:", "foo", "bar"), true, "multiple subprotocols"},
{terminal("ws:", ""), true, "websocket URL"}, {channel("ws:", ""), true, "websocket URL"},
{terminal("wss:", ""), true, "secure websocket URL"}, {channel("wss:", ""), true, "secure websocket URL"},
{terminal("http:", ""), false, "HTTP URL"}, {channel("http:", ""), false, "HTTP URL"},
{terminal("https:", ""), false, " HTTPS URL"}, {channel("https:", ""), false, " HTTPS URL"},
{ca(terminal("ws:", "")), true, "any CA pem"}, {ca(channel("ws:", "")), true, "any CA pem"},
{header(terminal("ws:", "")), true, "any headers"}, {header(channel("ws:", "")), true, "any headers"},
{ca(header(terminal("ws:", ""))), true, "PEM and headers"}, {ca(header(channel("ws:", ""))), true, "PEM and headers"},
} { } {
if err := tc.terminal.Validate(); (err != nil) == tc.valid { if err := tc.channel.Validate(); (err != nil) == tc.valid {
t.Fatalf("test case %d: "+tc.msg+": valid=%v: %s: %+v", i, tc.valid, err, tc.terminal) t.Fatalf("test case %d: "+tc.msg+": valid=%v: %s: %+v", i, tc.valid, err, tc.channel)
} }
} }
} }
func TestDialer(t *testing.T) { func TestDialer(t *testing.T) {
terminal := terminal("ws:", "foo") channel := channel("ws:", "foo")
dialer := terminal.Dialer() dialer := channel.Dialer()
if len(dialer.Subprotocols) != len(terminal.Subprotocols) { if len(dialer.Subprotocols) != len(channel.Subprotocols) {
t.Fatalf("Subprotocols don't match: %+v vs. %+v", terminal.Subprotocols, dialer.Subprotocols) t.Fatalf("Subprotocols don't match: %+v vs. %+v", channel.Subprotocols, dialer.Subprotocols)
} }
for i, subprotocol := range terminal.Subprotocols { for i, subprotocol := range channel.Subprotocols {
if dialer.Subprotocols[i] != subprotocol { if dialer.Subprotocols[i] != subprotocol {
t.Fatalf("Subprotocols don't match: %+v vs. %+v", terminal.Subprotocols, dialer.Subprotocols) t.Fatalf("Subprotocols don't match: %+v vs. %+v", channel.Subprotocols, dialer.Subprotocols)
} }
} }
...@@ -100,8 +100,8 @@ func TestDialer(t *testing.T) { ...@@ -100,8 +100,8 @@ func TestDialer(t *testing.T) {
t.Fatalf("Unexpected TLSClientConfig: %+v", dialer) t.Fatalf("Unexpected TLSClientConfig: %+v", dialer)
} }
terminal = ca(terminal) channel = ca(channel)
dialer = terminal.Dialer() dialer = channel.Dialer()
if dialer.TLSClientConfig == nil || dialer.TLSClientConfig.RootCAs == nil { if dialer.TLSClientConfig == nil || dialer.TLSClientConfig.RootCAs == nil {
t.Fatalf("Custom CA certificates not recognised!") t.Fatalf("Custom CA certificates not recognised!")
...@@ -109,45 +109,45 @@ func TestDialer(t *testing.T) { ...@@ -109,45 +109,45 @@ func TestDialer(t *testing.T) {
} }
func TestIsEqual(t *testing.T) { func TestIsEqual(t *testing.T) {
term := terminal("ws:", "foo") chann := channel("ws:", "foo")
term_header2 := header(term, "extra") chann_header2 := header(chann, "extra")
term_header3 := header(term) chann_header3 := header(chann)
term_header3.Header.Add("Extra", "extra") chann_header3.Header.Add("Extra", "extra")
term_ca2 := ca(term) chann_ca2 := ca(chann)
term_ca2.CAPem = "other value" chann_ca2.CAPem = "other value"
for i, tc := range []struct { for i, tc := range []struct {
termA *TerminalSettings channelA *ChannelSettings
termB *TerminalSettings channelB *ChannelSettings
expected bool expected bool
}{ }{
{nil, nil, true}, {nil, nil, true},
{term, nil, false}, {chann, nil, false},
{nil, term, false}, {nil, chann, false},
{term, term, true}, {chann, chann, true},
{term.Clone(), term.Clone(), true}, {chann.Clone(), chann.Clone(), true},
{term, terminal("foo:"), false}, {chann, channel("foo:"), false},
{term, terminal(term.Url), false}, {chann, channel(chann.Url), false},
{header(term), header(term), true}, {header(chann), header(chann), true},
{term_header2, term_header2, true}, {chann_header2, chann_header2, true},
{term_header3, term_header3, true}, {chann_header3, chann_header3, true},
{header(term), term_header2, false}, {header(chann), chann_header2, false},
{header(term), term_header3, false}, {header(chann), chann_header3, false},
{header(term), term, false}, {header(chann), chann, false},
{term, header(term), false}, {chann, header(chann), false},
{ca(term), ca(term), true}, {ca(chann), ca(chann), true},
{ca(term), term, false}, {ca(chann), chann, false},
{term, ca(term), false}, {chann, ca(chann), false},
{ca(header(term)), ca(header(term)), true}, {ca(header(chann)), ca(header(chann)), true},
{term_ca2, ca(term), false}, {chann_ca2, ca(chann), false},
{term, timeout(term), false}, {chann, timeout(chann), false},
} { } {
if actual := tc.termA.IsEqual(tc.termB); tc.expected != actual { if actual := tc.channelA.IsEqual(tc.channelB); tc.expected != actual {
t.Fatalf( t.Fatalf(
"test case %d: Comparison:\n-%+v\n+%+v\nexpected=%v: actual=%v", "test case %d: Comparison:\n-%+v\n+%+v\nexpected=%v: actual=%v",
i, tc.termA, tc.termB, tc.expected, actual, i, tc.channelA, tc.channelB, tc.expected, actual,
) )
} }
} }
......
...@@ -3,7 +3,6 @@ package api ...@@ -3,7 +3,6 @@ package api
import ( import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
...@@ -13,7 +12,7 @@ import ( ...@@ -13,7 +12,7 @@ import (
) )
type TerminalSettings struct { type TerminalSettings struct {
// The terminal provider may require use of a particular subprotocol. If so, // The channel provider may require use of a particular subprotocol. If so,
// it must be specified here, and Workhorse must have a matching codec. // it must be specified here, and Workhorse must have a matching codec.
Subprotocols []string Subprotocols []string
...@@ -61,62 +60,11 @@ func (t *TerminalSettings) Dial() (*websocket.Conn, *http.Response, error) { ...@@ -61,62 +60,11 @@ func (t *TerminalSettings) Dial() (*websocket.Conn, *http.Response, error) {
return t.Dialer().Dial(t.Url, t.Header) return t.Dialer().Dial(t.Url, t.Header)
} }
func (t *TerminalSettings) Validate() error { func (t *TerminalSettings) Channel() *ChannelSettings {
if t == nil { return &ChannelSettings{
return fmt.Errorf("terminal details not specified") Subprotocols: t.Subprotocols,
Url: t.Url,
CAPem: t.CAPem,
MaxSessionTime: t.MaxSessionTime,
} }
if len(t.Subprotocols) == 0 {
return fmt.Errorf("no subprotocol specified")
}
parsedURL, err := t.URL()
if err != nil {
return fmt.Errorf("invalid URL")
}
if parsedURL.Scheme != "ws" && parsedURL.Scheme != "wss" {
return fmt.Errorf("invalid websocket scheme: %q", parsedURL.Scheme)
}
return nil
}
func (t *TerminalSettings) IsEqual(other *TerminalSettings) bool {
if t == nil && other == nil {
return true
}
if t == nil || other == nil {
return false
}
if len(t.Subprotocols) != len(other.Subprotocols) {
return false
}
for i, subprotocol := range t.Subprotocols {
if other.Subprotocols[i] != subprotocol {
return false
}
}
if len(t.Header) != len(other.Header) {
return false
}
for header, values := range t.Header {
if len(values) != len(other.Header[header]) {
return false
}
for i, value := range values {
if other.Header[header][i] != value {
return false
}
}
}
return t.Url == other.Url &&
t.CAPem == other.CAPem &&
t.MaxSessionTime == other.MaxSessionTime
} }
package terminal package channel
import ( import (
"errors" "errors"
...@@ -8,13 +8,13 @@ import ( ...@@ -8,13 +8,13 @@ import (
"gitlab.com/gitlab-org/gitlab-workhorse/internal/api" "gitlab.com/gitlab-org/gitlab-workhorse/internal/api"
) )
type AuthCheckerFunc func() *api.TerminalSettings type AuthCheckerFunc func() *api.ChannelSettings
// Regularly checks that authorization is still valid for a terminal, outputting // Regularly checks that authorization is still valid for a channel, outputting
// to the stopper when it isn't // to the stopper when it isn't
type AuthChecker struct { type AuthChecker struct {
Checker AuthCheckerFunc Checker AuthCheckerFunc
Template *api.TerminalSettings Template *api.ChannelSettings
StopCh chan error StopCh chan error
Done chan struct{} Done chan struct{}
Count int64 Count int64
...@@ -22,7 +22,7 @@ type AuthChecker struct { ...@@ -22,7 +22,7 @@ type AuthChecker struct {
var ErrAuthChanged = errors.New("connection closed: authentication changed or endpoint unavailable") var ErrAuthChanged = errors.New("connection closed: authentication changed or endpoint unavailable")
func NewAuthChecker(f AuthCheckerFunc, template *api.TerminalSettings, stopCh chan error) *AuthChecker { func NewAuthChecker(f AuthCheckerFunc, template *api.ChannelSettings, stopCh chan error) *AuthChecker {
return &AuthChecker{ return &AuthChecker{
Checker: f, Checker: f,
Template: template, Template: template,
...@@ -53,7 +53,7 @@ func (c *AuthChecker) Close() error { ...@@ -53,7 +53,7 @@ func (c *AuthChecker) Close() error {
// Generates a CheckerFunc from an *api.API + request needing authorization // Generates a CheckerFunc from an *api.API + request needing authorization
func authCheckFunc(myAPI *api.API, r *http.Request, suffix string) AuthCheckerFunc { func authCheckFunc(myAPI *api.API, r *http.Request, suffix string) AuthCheckerFunc {
return func() *api.TerminalSettings { return func() *api.ChannelSettings {
httpResponse, authResponse, err := myAPI.PreAuthorize(suffix, r) httpResponse, authResponse, err := myAPI.PreAuthorize(suffix, r)
if err != nil { if err != nil {
return nil return nil
...@@ -64,6 +64,6 @@ func authCheckFunc(myAPI *api.API, r *http.Request, suffix string) AuthCheckerFu ...@@ -64,6 +64,6 @@ func authCheckFunc(myAPI *api.API, r *http.Request, suffix string) AuthCheckerFu
return nil return nil
} }
return authResponse.Terminal return authResponse.Channel
} }
} }
package terminal package channel
import ( import (
"testing" "testing"
...@@ -7,8 +7,8 @@ import ( ...@@ -7,8 +7,8 @@ import (
"gitlab.com/gitlab-org/gitlab-workhorse/internal/api" "gitlab.com/gitlab-org/gitlab-workhorse/internal/api"
) )
func checkerSeries(values ...*api.TerminalSettings) AuthCheckerFunc { func checkerSeries(values ...*api.ChannelSettings) AuthCheckerFunc {
return func() *api.TerminalSettings { return func() *api.ChannelSettings {
if len(values) == 0 { if len(values) == 0 {
return nil return nil
} }
...@@ -19,7 +19,7 @@ func checkerSeries(values ...*api.TerminalSettings) AuthCheckerFunc { ...@@ -19,7 +19,7 @@ func checkerSeries(values ...*api.TerminalSettings) AuthCheckerFunc {
} }
func TestAuthCheckerStopsWhenAuthFails(t *testing.T) { func TestAuthCheckerStopsWhenAuthFails(t *testing.T) {
template := &api.TerminalSettings{Url: "ws://example.com"} template := &api.ChannelSettings{Url: "ws://example.com"}
stopCh := make(chan error) stopCh := make(chan error)
series := checkerSeries(template, template, template) series := checkerSeries(template, template, template)
ac := NewAuthChecker(series, template, stopCh) ac := NewAuthChecker(series, template, stopCh)
...@@ -35,7 +35,7 @@ func TestAuthCheckerStopsWhenAuthFails(t *testing.T) { ...@@ -35,7 +35,7 @@ func TestAuthCheckerStopsWhenAuthFails(t *testing.T) {
} }
func TestAuthCheckerStopsWhenAuthChanges(t *testing.T) { func TestAuthCheckerStopsWhenAuthChanges(t *testing.T) {
template := &api.TerminalSettings{Url: "ws://example.com"} template := &api.ChannelSettings{Url: "ws://example.com"}
changed := template.Clone() changed := template.Clone()
changed.Url = "wss://example.com" changed.Url = "wss://example.com"
stopCh := make(chan error) stopCh := make(chan error)
......
package terminal package channel
import ( import (
"fmt" "fmt"
...@@ -13,7 +13,7 @@ import ( ...@@ -13,7 +13,7 @@ import (
) )
var ( var (
// See doc/terminal.md for documentation of this subprotocol // See doc/channel.md for documentation of this subprotocol
subprotocols = []string{"terminal.gitlab.com", "base64.terminal.gitlab.com"} subprotocols = []string{"terminal.gitlab.com", "base64.terminal.gitlab.com"}
upgrader = &websocket.Upgrader{Subprotocols: subprotocols} upgrader = &websocket.Upgrader{Subprotocols: subprotocols}
ReauthenticationInterval = 5 * time.Minute ReauthenticationInterval = 5 * time.Minute
...@@ -22,7 +22,15 @@ var ( ...@@ -22,7 +22,15 @@ var (
func Handler(myAPI *api.API) http.Handler { func Handler(myAPI *api.API) http.Handler {
return myAPI.PreAuthorizeHandler(func(w http.ResponseWriter, r *http.Request, a *api.Response) { return myAPI.PreAuthorizeHandler(func(w http.ResponseWriter, r *http.Request, a *api.Response) {
if err := a.Terminal.Validate(); err != nil { // Used during the transition from Terminal to Channel
// Once we remove the TerminalSettings object we can remove
// this condition
// https://gitlab.com/gitlab-org/gitlab-workhorse/issues/214
if a.Terminal != nil {
a.Channel = a.Terminal.Channel()
}
if err := a.Channel.Validate(); err != nil {
helper.Fail500(w, r, err) helper.Fail500(w, r, err)
return return
} }
...@@ -30,22 +38,22 @@ func Handler(myAPI *api.API) http.Handler { ...@@ -30,22 +38,22 @@ func Handler(myAPI *api.API) http.Handler {
proxy := NewProxy(2) // two stoppers: auth checker, max time proxy := NewProxy(2) // two stoppers: auth checker, max time
checker := NewAuthChecker( checker := NewAuthChecker(
authCheckFunc(myAPI, r, "authorize"), authCheckFunc(myAPI, r, "authorize"),
a.Terminal, a.Channel,
proxy.StopCh, proxy.StopCh,
) )
defer checker.Close() defer checker.Close()
go checker.Loop(ReauthenticationInterval) go checker.Loop(ReauthenticationInterval)
go closeAfterMaxTime(proxy, a.Terminal.MaxSessionTime) go closeAfterMaxTime(proxy, a.Channel.MaxSessionTime)
ProxyTerminal(w, r, a.Terminal, proxy) ProxyChannel(w, r, a.Channel, proxy)
}, "authorize") }, "authorize")
} }
func ProxyTerminal(w http.ResponseWriter, r *http.Request, terminal *api.TerminalSettings, proxy *Proxy) { func ProxyChannel(w http.ResponseWriter, r *http.Request, settings *api.ChannelSettings, proxy *Proxy) {
server, err := connectToServer(terminal, r) server, err := connectToServer(settings, r)
if err != nil { if err != nil {
helper.Fail500(w, r, err) helper.Fail500(w, r, err)
log.WithError(r.Context(), err).Print("Terminal: connecting to server failed") log.WithError(r.Context(), err).Print("Channel: connecting to server failed")
return return
} }
defer server.UnderlyingConn().Close() defer server.UnderlyingConn().Close()
...@@ -53,7 +61,7 @@ func ProxyTerminal(w http.ResponseWriter, r *http.Request, terminal *api.Termina ...@@ -53,7 +61,7 @@ func ProxyTerminal(w http.ResponseWriter, r *http.Request, terminal *api.Termina
client, err := upgradeClient(w, r) client, err := upgradeClient(w, r)
if err != nil { if err != nil {
log.WithError(r.Context(), err).Print("Terminal: upgrading client to websocket failed") log.WithError(r.Context(), err).Print("Channel: upgrading client to websocket failed")
return return
} }
...@@ -69,12 +77,12 @@ func ProxyTerminal(w http.ResponseWriter, r *http.Request, terminal *api.Termina ...@@ -69,12 +77,12 @@ func ProxyTerminal(w http.ResponseWriter, r *http.Request, terminal *api.Termina
"serverAddr": serverAddr, "serverAddr": serverAddr,
}) })
logEntry.Print("Terminal: started proxying") logEntry.Print("Channel: started proxying")
defer logEntry.Print("Terminal: finished proxying") defer logEntry.Print("Channel: finished proxying")
if err := proxy.Serve(server, client, serverAddr, clientAddr); err != nil { if err := proxy.Serve(server, client, serverAddr, clientAddr); err != nil {
logEntry.WithError(err).Print("Terminal: error proxying") logEntry.WithError(err).Print("Channel: error proxying")
} }
} }
...@@ -105,12 +113,12 @@ func pingLoop(conn Connection) { ...@@ -105,12 +113,12 @@ func pingLoop(conn Connection) {
} }
} }
func connectToServer(terminal *api.TerminalSettings, r *http.Request) (Connection, error) { func connectToServer(settings *api.ChannelSettings, r *http.Request) (Connection, error) {
terminal = terminal.Clone() settings = settings.Clone()
helper.SetForwardedFor(&terminal.Header, r) helper.SetForwardedFor(&settings.Header, r)
conn, _, err := terminal.Dial() conn, _, err := settings.Dial()
if err != nil { if err != nil {
return nil, err return nil, err
} }
......
package terminal package channel
import ( import (
"fmt" "fmt"
...@@ -8,7 +8,7 @@ import ( ...@@ -8,7 +8,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
// ANSI "end of terminal" code // ANSI "end of channel" code
var eot = []byte{0x04} var eot = []byte{0x04}
// An abstraction of gorilla's *websocket.Conn // An abstraction of gorilla's *websocket.Conn
...@@ -31,7 +31,7 @@ func NewProxy(stoppers int) *Proxy { ...@@ -31,7 +31,7 @@ func NewProxy(stoppers int) *Proxy {
} }
func (p *Proxy) Serve(upstream, downstream Connection, upstreamAddr, downstreamAddr string) error { func (p *Proxy) Serve(upstream, downstream Connection, upstreamAddr, downstreamAddr string) error {
// This signals the upstream terminal to kill the exec'd process // This signals the upstream channel to kill the exec'd process
defer upstream.WriteMessage(websocket.BinaryMessage, eot) defer upstream.WriteMessage(websocket.BinaryMessage, eot)
go p.proxy(upstream, downstream, upstreamAddr, downstreamAddr) go p.proxy(upstream, downstream, upstreamAddr, downstreamAddr)
......
package terminal package channel
import ( import (
"encoding/base64" "encoding/base64"
......
package terminal package channel
import ( import (
"bytes" "bytes"
......
...@@ -13,6 +13,7 @@ import ( ...@@ -13,6 +13,7 @@ import (
apipkg "gitlab.com/gitlab-org/gitlab-workhorse/internal/api" apipkg "gitlab.com/gitlab-org/gitlab-workhorse/internal/api"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/artifacts" "gitlab.com/gitlab-org/gitlab-workhorse/internal/artifacts"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/builds" "gitlab.com/gitlab-org/gitlab-workhorse/internal/builds"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/channel"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/filestore" "gitlab.com/gitlab-org/gitlab-workhorse/internal/filestore"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/git" "gitlab.com/gitlab-org/gitlab-workhorse/internal/git"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/helper" "gitlab.com/gitlab-org/gitlab-workhorse/internal/helper"
...@@ -25,7 +26,6 @@ import ( ...@@ -25,7 +26,6 @@ import (
"gitlab.com/gitlab-org/gitlab-workhorse/internal/sendfile" "gitlab.com/gitlab-org/gitlab-workhorse/internal/sendfile"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/sendurl" "gitlab.com/gitlab-org/gitlab-workhorse/internal/sendurl"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/staticpages" "gitlab.com/gitlab-org/gitlab-workhorse/internal/staticpages"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/terminal"
"gitlab.com/gitlab-org/gitlab-workhorse/internal/upload" "gitlab.com/gitlab-org/gitlab-workhorse/internal/upload"
) )
...@@ -187,8 +187,8 @@ func (u *upstream) configureRoutes() { ...@@ -187,8 +187,8 @@ func (u *upstream) configureRoutes() {
route("POST", ciAPIPattern+`v1/builds/[0-9]+/artifacts\z`, contentEncodingHandler(artifacts.UploadArtifacts(api, proxy))), route("POST", ciAPIPattern+`v1/builds/[0-9]+/artifacts\z`, contentEncodingHandler(artifacts.UploadArtifacts(api, proxy))),
// Terminal websocket // Terminal websocket
wsRoute(projectPattern+`environments/[0-9]+/terminal.ws\z`, terminal.Handler(api)), wsRoute(projectPattern+`environments/[0-9]+/terminal.ws\z`, channel.Handler(api)),
wsRoute(projectPattern+`-/jobs/[0-9]+/terminal.ws\z`, terminal.Handler(api)), wsRoute(projectPattern+`-/jobs/[0-9]+/terminal.ws\z`, channel.Handler(api)),
// Long poll and limit capacity given to jobs/request and builds/register.json // Long poll and limit capacity given to jobs/request and builds/register.json
route("", apiPattern+`v4/jobs/request\z`, ciAPILongPolling), route("", apiPattern+`v4/jobs/request\z`, ciAPILongPolling),
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment