sketch: Linux support
Together with 8a89e1cbd3e7c9bbe1c561e88141527a0c894e06, this
should resolve https://github.com/boldsoftware/sketch/issues/3,
in supporting Linux.
To test on a Mac, run "limactl shell default go run ./cmd/sketch -verbose", assuming
you have a lima default VM with sketch's dependencies already prepped.
The issues we encountered were:
1. Explicit checks for "is Colima installed", "is docker installed",
etc. Easy enough to fix these.
2. When go's toolchain builds, it puts the output in different places
depending whether it's cross-compiled or not. Fine, we adapt.
Note that os.Rename() doesn't always work, and we fall back to
copy/delete. Mode has to be set appropriately to, to make the
/bin/sketch executable executable.
Re-building is kind of silly: we could use /proc/self/exe, for
example, or cross-mount the binary into the container, or any
number of other things. That said, here we keep the paths the same.
3. Docker networking setup for the git server. The host git repo
is served with git's cgi-bin wrapper. host.docker.internal
seems to work different in colima vs Ubuntu's docker.io, so
an extra command line flag to docker was added. Then,
it turns out that our git server checks that the remote is
127.0.0.1, which seems to be how Colima does its networking,
but isn't the case for Docker in general. To handle this, we
use HTTP Basic Auth with a random password. (Git also has
an "http.extraHeader" configuration, but basic auth seems just
as good and more obvious.)
Incidentally, I'm also adding the git remote as a git remote
named "sketch-host" to make it easier to work with in the terminal,
if necessary.
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 481d24e..039c84d 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -4,6 +4,7 @@
import (
"bytes"
"context"
+ "crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -72,7 +73,11 @@
// It writes status to stdout.
func LaunchContainer(ctx context.Context, stdout, stderr io.Writer, config ContainerConfig) error {
if _, err := exec.LookPath("docker"); err != nil {
- return fmt.Errorf("cannot find `docker` binary; run: brew install docker colima && colima start")
+ if runtime.GOOS == "darwin" {
+ return fmt.Errorf("cannot find `docker` binary; run: brew install docker colima && colima start")
+ } else {
+ return fmt.Errorf("cannot find `docker` binary; install docker (e.g., apt-get install docker.io)")
+ }
}
if out, err := combinedOutput(ctx, "docker", "ps"); err != nil {
@@ -241,7 +246,7 @@
// the scrollback (which is not good, but also not fatal). I can't see why it does this
// though, since none of the calls in postContainerInitConfig obviously write to stdout
// or stderr.
- if err := postContainerInitConfig(ctx, localAddr, commit, gitSrv.gitPort); err != nil {
+ if err := postContainerInitConfig(ctx, localAddr, commit, gitSrv.gitPort, gitSrv.pass); err != nil {
slog.ErrorContext(ctx, "LaunchContainer.postContainerInitConfig", slog.String("err", err.Error()))
errCh <- appendInternalErr(err)
}
@@ -304,6 +309,7 @@
gitLn net.Listener
gitPort string
srv *http.Server
+ pass string
}
func (gs *gitServer) shutdown(ctx context.Context) {
@@ -317,8 +323,18 @@
return gs.srv.Serve(gs.gitLn)
}
+func mkRandToken() string {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ panic(err)
+ }
+ return hex.EncodeToString(b[:])
+}
+
func newGitServer(gitRoot string) (*gitServer, error) {
ret := &gitServer{}
+ ret.pass = mkRandToken()
+
gitLn, err := net.Listen("tcp4", ":0")
if err != nil {
return nil, fmt.Errorf("git listen: %w", err)
@@ -326,7 +342,7 @@
ret.gitLn = gitLn
srv := http.Server{
- Handler: &gitHTTP{gitRepoRoot: gitRoot},
+ Handler: &gitHTTP{gitRepoRoot: gitRoot, pass: ret.pass},
}
ret.srv = &srv
@@ -357,6 +373,8 @@
if relPath != "." {
cmdArgs = append(cmdArgs, "-w", "/app/"+relPath)
}
+ // colima does this by default, but Linux docker seems to need this set explicitly
+ cmdArgs = append(cmdArgs, "--add-host", "host.docker.internal:host-gateway")
cmdArgs = append(
cmdArgs,
imgName,
@@ -415,9 +433,16 @@
slog.DebugContext(ctx, "go", slog.Duration("elapsed", time.Now().Sub(start)), slog.String("path", cmd.Path), slog.String("args", fmt.Sprintf("%v", skribe.Redact(cmd.Args))))
}
- src := filepath.Join(linuxGopath, "bin", "linux_"+runtime.GOARCH, "sketch")
+ var src string
+ if runtime.GOOS != "linux" {
+ src = filepath.Join(linuxGopath, "bin", "linux_"+runtime.GOARCH, "sketch")
+ } else {
+ // If we are already on Linux, there's no extra platform name in the path
+ src = filepath.Join(linuxGopath, "bin", "sketch")
+ }
+
dst := filepath.Join(path, "tmp-sketch-binary-linux")
- if err := os.Rename(src, dst); err != nil {
+ if err := moveFile(src, dst); err != nil {
return "", err
}
@@ -444,11 +469,11 @@
}
// Contact the container and configure it.
-func postContainerInitConfig(ctx context.Context, localAddr, commit, gitPort string) error {
+func postContainerInitConfig(ctx context.Context, localAddr, commit, gitPort string, gitPass string) error {
localURL := "http://" + localAddr
initMsg, err := json.Marshal(map[string]string{
"commit": commit,
- "git_remote_addr": "http://host.docker.internal:" + gitPort + "/.git",
+ "git_remote_addr": fmt.Sprintf("http://sketch:%s@host.docker.internal:%s/.git", gitPass, gitPort),
"host_addr": localAddr,
})
if err != nil {
@@ -671,3 +696,40 @@
fmt.Fprintf(os.Stderr, "failed to open browser: %v: %s\n", err, b)
}
}
+
+// moveFile is like Python's shutil.move, in that it tries a rename, and, if that fails,
+// copies and deletes
+func moveFile(src, dst string) error {
+ if err := os.Rename(src, dst); err == nil {
+ return nil
+ }
+
+ stat, err := os.Stat(src)
+ if err != nil {
+ return err
+ }
+
+ sourceFile, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer sourceFile.Close()
+
+ destFile, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer destFile.Close()
+
+ _, err = io.Copy(destFile, sourceFile)
+ if err != nil {
+ return err
+ }
+
+ sourceFile.Close()
+ destFile.Close()
+
+ os.Chmod(dst, stat.Mode())
+
+ return os.Remove(src)
+}
diff --git a/dockerimg/githttp.go b/dockerimg/githttp.go
index d618ab6..6f0ec55 100644
--- a/dockerimg/githttp.go
+++ b/dockerimg/githttp.go
@@ -1,16 +1,19 @@
package dockerimg
import (
+ "crypto/subtle"
"fmt"
"log/slog"
"net/http"
"net/http/cgi"
"os/exec"
+ "runtime"
"strings"
)
type gitHTTP struct {
gitRepoRoot string
+ pass string
}
func (g *gitHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -22,11 +25,40 @@
http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
}
}()
- if !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {
- slog.InfoContext(r.Context(), "githttp: denied", "remote addr", r.RemoteAddr)
- http.Error(w, "no", http.StatusUnauthorized)
+
+ // Get the Authorization header
+ username, password, ok := r.BasicAuth()
+
+ // Check if credentials were provided
+ if !ok {
+ // No credentials provided, return 401 Unauthorized
+ w.Header().Set("WWW-Authenticate", `Basic realm="Sketch Git Repository"`)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ slog.InfoContext(r.Context(), "githttp: denied (basic auth)", "remote addr", r.RemoteAddr)
return
}
+
+ // Perform constant-time comparison to prevent timing attacks
+ usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte("sketch")) == 1
+ passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(g.pass)) == 1
+
+ // Check if credentials are valid
+ if !usernameMatch || !passwordMatch {
+ w.Header().Set("WWW-Authenticate", `Basic realm="Git Repository"`)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ slog.InfoContext(r.Context(), "githttp: denied (basic auth)", "remote addr", r.RemoteAddr)
+ return
+ }
+
+ if runtime.GOOS == "darwin" {
+ // On the Mac, Docker connections show up from localhost. On Linux, the docker
+ // network is more arbitrary, so we don't do this additional check there.
+ if !strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {
+ slog.InfoContext(r.Context(), "githttp: denied", "remote addr", r.RemoteAddr)
+ http.Error(w, "no", http.StatusUnauthorized)
+ return
+ }
+ }
gitBin, err := exec.LookPath("git")
if err != nil {
http.Error(w, "no git: "+err.Error(), http.StatusInternalServerError)