diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af7defe..6c3a6cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,18 @@ -name: CI +name: Build & Release + on: pull_request: {} push: {} workflow_dispatch: inputs: {} + +permissions: + contents: write + packages: write + jobs: - ci: - name: CI + build-binary: + name: build-binary runs-on: ubuntu-latest steps: - name: Checkout @@ -16,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: '1.19' + go-version: '1.22' check-latest: true cache: true - name: Build @@ -31,4 +37,52 @@ jobs: version: latest args: release --rm-dist --config .github/goreleaser.yml env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + docker-build: + runs-on: ubuntu-latest + name: Deploy to Docker Image + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Image Tag from branch name + if: startsWith(github.ref, 'refs/heads/release') + run: | + set +e + IMAGE_TAG=$(echo ${GITHUB_REF#refs/heads/} | sed 's/release-//g') + echo "$IMAGE_TAG" | grep -i '\-nightly$' + if [ $? -ne 0 ]; then + IMAGE_TAG="$IMAGE_TAG-nightly" + fi + set -e + + echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV + echo "OVERRIDE_PUSHED_IMAGE=true" >> $GITHUB_ENV + + - name: Create Image Tag from tag + if: startsWith(github.ref, 'refs/tags/') + run: | + IMAGE_TAG=$(echo ${GITHUB_REF#refs/tags/}) + + echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV + echo "OVERRIDE_PUSHED_IMAGE=false" >> $GITHUB_ENV + + - name: Build & Push Image + if: startsWith(github.ref, 'refs/heads/release') || startsWith(github.ref, 'refs/tags/') + run: | + image_name="ghcr.io/${{ github.repository }}" + + docker build -t $image_name:$IMAGE_TAG . + docker push $image_name:$IMAGE_TAG + diff --git a/.gitignore b/.gitignore index 32081ed..c8ff98f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ tmp/ test.sh +bin diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e750014 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1.4 +FROM golang:1.22.0-alpine3.18 AS base +USER 1001 +ENV GOPATH=/tmp/go +ENV GOCACHE=/tmp/go-cache +WORKDIR /tmp/app +COPY . . +RUN go mod download -x + +RUN CGO_ENABLED=0 go build -o /tmp/bin/bin-installer ./main.go +RUN chmod +x /tmp/bin/bin-installer + +FROM gcr.io/distroless/static-debian11:nonroot +LABEL org.opencontainers.image.source=https://github.com/kloudlite/bin-installer +COPY --from=base /tmp/bin/bin-installer ./bin-installer +CMD ["./bin-installer"] diff --git a/README.md b/README.md index 30e356b..565879b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ curl https://i.jpillora.com/! | bash *Or you can use* `wget -qO- | bash` +*For windows use* `iwr | iex` + **Path API** * `user` Github user (defaults to @jpillora, customisable if you [host your own](#host-your-own), searches the web to pick most relevant `user` when `repo` not found) @@ -36,6 +38,8 @@ curl https://i.jpillora.com/! | bash * `type=homebrew` is **not** working at the moment – see [Homebrew](#homebrew) * `?insecure=1` Force `curl`/`wget` to skip certificate checks * `?as=` Force the binary to be named as this parameter value +* `?select=` Select binary, if **repository name** and **binary name** in release differs. + * **eg**: repo_name is **foobar** and binary name is **fb-client** and **fb-server** in release, Then `?select=fb-client` & `?select=fb-server` accordingly. ## Security diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..997a75d --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,12 @@ +version: 3 + +tasks: + run: + cmds: + - go run main.go + dev: + cmds: + - nodemon -q -e 'go,tmpl' --signal SIGTERM --exec "echo '# building' && task build && echo '# build success' && ./bin/installer || exit" + build: + cmds: + - go build -o ./bin/installer main.go diff --git a/go.mod b/go.mod index da7d879..2527e28 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/jpillora/installer -go 1.18 +go 1.19 require ( github.com/jpillora/opts v1.1.2 diff --git a/handler/handler.go b/handler/handler.go index 76c0db5..6982cb0 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -26,14 +26,15 @@ const ( var ( isTermRe = regexp.MustCompile(`(?i)^(curl|wget)\/`) isHomebrewRe = regexp.MustCompile(`(?i)^homebrew`) + isPowershell = regexp.MustCompile(`(?i)windows`) errMsgRe = regexp.MustCompile(`[^A-Za-z0-9\ :\/\.]`) errNotFound = errors.New("not found") ) type Query struct { - User, Program, AsProgram, Release string - MoveToPath, Search, Insecure bool - SudoMove bool // deprecated: not used, now automatically detected + User, Program, AsProgram, Selected, Release string + MoveToPath, Search, Insecure bool + SudoMove bool // deprecated: not used, now automatically detected } type Result struct { @@ -76,12 +77,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { qtype = "script" case isHomebrewRe.MatchString(ua): qtype = "ruby" + case isPowershell.MatchString(ua): + qtype = "powershell" default: qtype = "text" } } // type specific error response - showError := func(msg string, code int) { + showError := func(msg string, _ int) { // prevent shell injection cleaned := errMsgRe.ReplaceAllString(msg, "") if qtype == "script" { @@ -102,6 +105,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") ext = "txt" script = string(scripts.Text) + case "powershell": + w.Header().Set("Content-Type", "text/plain") + ext = "ps1" + script = string(scripts.Powershell) default: showError("Unknown type", http.StatusInternalServerError) return @@ -110,6 +117,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { User: "", Program: "", Release: "", + Selected: r.URL.Query().Get("select"), Insecure: r.URL.Query().Get("insecure") == "1", AsProgram: r.URL.Query().Get("as"), } @@ -120,9 +128,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { q.MoveToPath = true path = strings.TrimRight(path, "!") } - var rest string - q.User, rest = splitHalf(path, "/") - q.Program, q.Release = splitHalf(rest, "@") + + var initial string + initial, q.Release = splitHalf(path, "@") + q.User, q.Program = splitHalf(initial, "/") + + // change binary name to selected-binary + if q.AsProgram == "" && q.Selected != "" { + q.AsProgram = q.Selected + } + // no program? treat first part as program, use default user if q.Program == "" { q.Program = q.User @@ -143,6 +158,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.Config.ForceRepo != "" { q.Program = h.Config.ForceRepo } + // validate query valid := q.Program != "" if !valid && path == "" { @@ -160,6 +176,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { showError(err.Error(), http.StatusBadGateway) return } + // load template t, err := template.New("installer").Parse(script) if err != nil { diff --git a/handler/handler_execute.go b/handler/handler_execute.go index f04292c..d93e9d5 100644 --- a/handler/handler_execute.go +++ b/handler/handler_execute.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/http" + "slices" "strings" "time" ) @@ -117,9 +118,18 @@ func (h *Handler) getAssetsNoCache(q Query) (string, Assets, error) { //only binary containers are supported //TODO deb,rpm etc fext := getFileExt(url) + + if q.Selected != "" { + //filter binary with it's name + if len(ga.Name) > len(q.Selected)+1 && !strings.Contains(ga.Name[0:len(q.Selected)+1], fmt.Sprint(q.Selected, "-")) { + continue + } + } + if fext == "" && ga.Size > 1024*1024 { fext = ".bin" // +1MB binary } + switch fext { case ".bin", ".zip", ".tar.bz", ".tar.bz2", ".bz2", ".gz", ".tar.gz", ".tgz": // valid @@ -132,10 +142,11 @@ func (h *Handler) getAssetsNoCache(q Query) (string, Assets, error) { arch := getArch(ga.Name) //windows not supported yet if os == "windows" { - log.Printf("fetched asset is for windows: %s", ga.Name) - //TODO: powershell - // EG: iwr https://deno.land/x/install/install.ps1 -useb | iex - continue + // with windows system commands, only zip can be extracted + if !slices.Contains([]string{".zip"}, fext) { + log.Printf("windows don't support fileextension %s ", fext) + continue + } } //unknown os, cant use if os == "" { diff --git a/scripts/install.ps1.tmpl b/scripts/install.ps1.tmpl new file mode 100644 index 0000000..e8aa18b --- /dev/null +++ b/scripts/install.ps1.tmpl @@ -0,0 +1,108 @@ +$user = "{{.User}}" +$prog="{{ .Program }}" +$sel_bin="{{ .Selected }}" +$asProgram="{{ .AsProgram }}" +$move="{{ .MoveToPath }}" +$release="{{ .Release }}" +$insecure="{{ .Insecure }}" + +$arch = "$env:PROCESSOR_ARCHITECTURE".ToLower() +$url = "" +$fext = "" + +{{- range .Assets}} {{- if eq .OS "windows" }} +if ("{{.Arch}}" -eq $arch -and "{{.Type}}" -eq ".zip"){ + $url = "{{.URL}}" + $fext = "{{.Type}}" +} +{{- end}} {{- end}} + +if($url -eq ""){ + echo "No asset for platform windows-$arch" + return +} + + +if ($move -eq "true"){ + # $out_dir="$env:USERPROFILE\bin" + $out_dir="C:\bin" +}else{ + $out_dir="$PWD" +} + +# Create the destination directory if it doesn't exist +if (-not (Test-Path $out_dir)) { + New-Item -ItemType Directory -Force -Path $out_dir +} + +$filename = "" + +# to extract another file type rather than zip, third party application is needed. +# so as windows only capable of extracting zip file with system commands. +switch ($fext) +{ + .zip { + $filename = "app-$prog-$arch.zip"; + } +} + +if("" -eq $filename){ + echo "file extension $fext not supported" + return +} + +$zipFilePath = "$env:TEMP\$filename"; +$extPath = "$env:TEMP\$prog" + +# Downloading File +if($sel_bin){ + echo "[#] downloading $user/$prog/$sel_bin as $asProgram from $url" +}else{ + echo "[#] downloading $user/$prog as $asProgram from $url" +} + +Invoke-WebRequest -Uri $url -OutFile $zipFilePath +# Extracting zip +echo "[#] extracting file" +Expand-Archive -Path $zipFilePath -DestinationPath $extPath +# Moving To out_dir +Get-ChildItem -Path $extPath -Filter *.exe -Recurse | Move-Item -Destination $out_dir -Force +# Clean up the downloaded ZIP and temporary extracted folder +Remove-Item -Path $zipFilePath -Force +Remove-Item -Path $extPath -Recurse -Force + +echo "[#] downloaded successfully to path $out_dir" +if ($move -eq "false"){ + return +} + +echo "[#] setting $out_dir to path" +# Get the current user's PATH environment variable +$currentPath = [System.Environment]::GetEnvironmentVariable("PATH", [System.EnvironmentVariableTarget]::User) + +# Split the PATH variable into an array of individual paths +$pathArray = $currentPath -split ";" + +$hasPath = "false" +# Iterate over each path in the PATH variable +foreach ($path in $pathArray) { + # Check if the current path contains the specific directory + if ($path -eq $out_dir) { + $hasPath = "true" + } +} + +if ($hasPath -eq "false") { + # Update the PATH environment variable + if (-not [string]::IsNullOrWhiteSpace($currentPath)) { + $updatedPath = $currentPath + ";" + $out_dir + } else { + $updatedPath = $out_dir + } + + # Set the updated PATH + [System.Environment]::SetEnvironmentVariable("PATH", $updatedPath, [System.EnvironmentVariableTarget]::User) + $env:Path = $out_dir +} + +echo "[#] installation complete" diff --git a/scripts/install.sh.tmpl b/scripts/install.sh.tmpl index 5a7101d..4c3c9e6 100644 --- a/scripts/install.sh.tmpl +++ b/scripts/install.sh.tmpl @@ -17,6 +17,7 @@ function install { #settings USER="{{ .User }}" PROG="{{ .Program }}" + SEL_BIN="{{ .Selected }}" ASPROG="{{ .AsProgram }}" MOVE="{{ .MoveToPath }}" RELEASE="{{ .Release }}" @@ -93,6 +94,9 @@ function install { #got URL! download it... echo -n "{{ if .MoveToPath }}Installing{{ else }}Downloading{{ end }}" echo -n " $USER/$PROG" + if [ ! -z "$SEL_BIN" ]; then + echo -n "/$SEL_BIN" + fi if [ ! -z "$RELEASE" ]; then echo -n " $RELEASE" fi diff --git a/scripts/install.txt.tmpl b/scripts/install.txt.tmpl index bb601f5..2791377 100644 --- a/scripts/install.txt.tmpl +++ b/scripts/install.txt.tmpl @@ -1,6 +1,7 @@ repository: https://github.com/{{ .User }}/{{ .Program }} user: {{ .User }} -program: {{ .Program }}{{if .AsProgram }} +program: {{ .Program }}{{if .Selected }} +selected-binary: {{.Selected}}{{end}}{{if .AsProgram }} as: {{ .AsProgram }}{{end}} release: {{ .Release }} move-into-path: {{ .MoveToPath }} diff --git a/scripts/scripts.go b/scripts/scripts.go index 1701816..6493539 100644 --- a/scripts/scripts.go +++ b/scripts/scripts.go @@ -10,3 +10,6 @@ var Shell []byte //go:embed install.rb.tmpl var Homebrew []byte + +//go:embed install.ps1.tmpl +var Powershell []byte