Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: Release

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Version tag to release from master (for example v0.1.0 or v0.1.0-rc.1)"
required: true
type: string

permissions:
contents: read

concurrency:
group: release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false

jobs:
test:
uses: ./.github/workflows/test.yml

publish:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Validate the release target
shell: bash
run: |
if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
echo "Expected vMAJOR.MINOR.PATCH with an optional prerelease suffix." >&2
exit 1
fi
if [[ "$RELEASE_TAG" == *-* ]]; then
IFS='.' read -ra identifiers <<< "${RELEASE_TAG#*-}"
for identifier in "${identifiers[@]}"; do
if [[ "$identifier" =~ ^0[0-9]+$ ]]; then
echo "Numeric prerelease identifiers must not have leading zeroes." >&2
exit 1
fi
done
fi
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch && "$GITHUB_REF" != refs/heads/master ]]; then
echo "Run manual releases from master." >&2
exit 1
fi
git merge-base --is-ancestor HEAD origin/master
if git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then
test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$(git rev-parse HEAD)"
elif [[ "$GITHUB_EVENT_NAME" != workflow_dispatch ]]; then
echo "The pushed release tag must already exist." >&2
exit 1
fi
published=$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases" --jq ".[] | select(.tag_name == \"$RELEASE_TAG\") | .draft")
if [[ "$published" == false ]]; then
echo "This version is already published. Create a new version instead of overwriting it." >&2
exit 1
fi
- name: Create the annotated tag for a manual release
if: github.event_name == 'workflow_dispatch'
shell: bash
run: |
if ! git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"; then
tag_object=$(gh api --method POST "repos/$GITHUB_REPOSITORY/git/tags" \
-f tag="$RELEASE_TAG" -f message="$RELEASE_TAG" \
-f object="$(git rev-parse HEAD)" -f type=commit --jq .sha)
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$RELEASE_TAG" -f sha="$tag_object" --silent
git fetch --no-tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
fi
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- name: Build archives and upload a draft release
uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0
with:
version: v2.19.0
args: release --clean
env:
GITHUB_TOKEN: ${{ github.token }}
GORELEASER_CURRENT_TAG: ${{ env.RELEASE_TAG }}
- name: Attest the release archives
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-checksums: ./dist/checksums.txt
- name: Publish only after every archive and attestation succeeds
shell: bash
run: |
if [[ "$RELEASE_TAG" == *-* ]]; then
gh release edit "$RELEASE_TAG" --draft=false --verify-tag --latest=false
else
gh release edit "$RELEASE_TAG" --draft=false --verify-tag
fi
49 changes: 46 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ name: Test

on:
push:
branches: [master]
pull_request:
workflow_call:

permissions:
contents: read
Expand All @@ -16,8 +18,10 @@ jobs:
go-version: ["1.20.x", "stable"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ matrix.go-version }}
- run: go test -race -timeout=3m ./...
Expand All @@ -27,5 +31,44 @@ jobs:
run: |
go build -o "$RUNNER_TEMP/tclient" ./cmd/tclient
python3 scripts/test_cli_pty.py "$RUNNER_TEMP/tclient"
- run: cd _example/telnet_client && go test ./...
- run: cd _example/monitor && go test ./...

release-snapshot:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: stable
- name: Check release configuration
uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0
with:
version: v2.19.0
args: check
- name: Build release archives without publishing
uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0
with:
version: v2.19.0
args: release --snapshot --clean
- name: Verify checksums and the Linux CLI archive
shell: bash
run: |
(cd dist && sha256sum --check checksums.txt)
mkdir -p "$RUNNER_TEMP/tclient-release-smoke"
tar -xzf dist/tclient_*_linux_amd64.tar.gz -C "$RUNNER_TEMP/tclient-release-smoke"
"$RUNNER_TEMP/tclient-release-smoke/tclient" --version
"$RUNNER_TEMP/tclient-release-smoke/tclient" --help
- name: Upload reviewable release archives
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: tclient-snapshot-${{ github.sha }}
path: |
dist/*.tar.gz
dist/*.zip
dist/checksums.txt
if-no-files-found: error
retention-days: 7
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@

# Dependency directories (remove the comment below to include it)
# vendor/
.idea/
.idea/

# CLI builds and GoReleaser output
/tclient
/dist/
74 changes: 74 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
version: 2

project_name: tclient

builds:
- id: tclient
main: ./cmd/tclient
binary: tclient
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
flags:
- -trimpath
ldflags:
- >-
-s -w
-X github.com/LeeEirc/tclientlib/internal/cli.version={{ .Version }}
-X github.com/LeeEirc/tclientlib/internal/cli.commit={{ .FullCommit }}
-X github.com/LeeEirc/tclientlib/internal/cli.date={{ .Date }}
mod_timestamp: "{{ .CommitTimestamp }}"

archives:
- id: tclient
ids:
- tclient
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
files:
- README.md
- CHANGELOG.md
- docs/*.md

checksum:
name_template: checksums.txt
algorithm: sha256

changelog:
sort: asc

release:
draft: true
prerelease: auto
replace_existing_draft: true
header: |
## Upgrade notes

Read the [changelog and migration guide](https://github.com/LeeEirc/tclientlib/blob/{{ .Tag }}/CHANGELOG.md)
for release highlights and compatibility changes.

**Upgrading from v0.0.x:** v0.1.0 changes the library configuration API and
requires Go 1.20 or later. `WindowChange` now takes **height before width**;
old calls still compile, so update them explicitly. The migration guide
also covers prompt matching, login line endings, and removed APIs.
footer: |
## Downloads

Archives contain the `tclient` binary, changelog, and CLI documentation. Verify the
download against `checksums.txt`, then run `tclient --version` to inspect
its version and build information. GitHub build attestations are available
for the archives.

Interactive terminals are supported on Unix. Windows supports redirected
input and output; Windows TTY input is not supported.
104 changes: 104 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Changelog

The Go library and `tclient` command share the same version. Entries describe
user-visible changes; the GitHub Release also lists the commits since the
previous tag.

## v0.1.0

This is the first release with downloadable `tclient` binaries. It also includes
the library redesign since `v0.0.2`, with the incompatible changes below.

### Added and improved

- A supported client at `cmd/tclient`, with manual sessions, opt-in automatic
login, charset conversion, local commands, window resizing, and redirected
input/output. `--version` reports the version, commit, build time, compiler,
and target platform.
- Archives for Linux, macOS, and Windows on amd64 and arm64, with SHA-256
checksums and GitHub build attestations. Unix supports interactive terminals;
Windows currently supports redirected sessions only.
- A stateful login matcher with bounded buffering, complete-line prompt
matching, password-change detection, and preservation of data received after
the shell prompt. Partial failure messages and banner text no longer count
as successful login prompts.
- Telnet stream handling for fragmented commands, escaped IAC bytes, directional
BINARY/NVT decoding, SGA, terminal type/speed, NAWS, and environment negotiation.
Only the configured user is exposed through environment negotiation.
- `WrapStream` for application-stream adapters, per-client tracing, explicit
`SendCommand` controls, and a `TerminalMode` negotiation snapshot.
- Regression coverage for protocol/login boundaries, concurrency, native
Linux/Windows builds, and interactive Linux PTY/TCP sessions.

### Upgrade from v0.0.x

**Review every `WindowChange` call: the argument order changed from
`WindowChange(width, height)` to `WindowChange(height, width)`.** Both arguments
are still integers, so old calls compile but send the wrong dimensions. The
new order matches `ssh.Session.WindowChange`.

The minimum supported Go version is now **Go 1.20**, up from Go 1.15.

#### Configuration and public API

| v0.0.x | v0.1.0 migration |
| --- | --- |
| `Config` | Use `ClientConfig` in `Dial` and `NewClientConn`. |
| `Config.Username` | Use `ClientConfig.User`. |
| `TTYOptions: &TerminalOptions{Wide, High, TermType}` | Set `Width`, `Height`, and `TerminalType` directly on `ClientConfig`. |
| `Config.SetDefaults()` | Constructors copy and validate the configuration and apply defaults; pass a non-nil `&ClientConfig{}` for default settings. |
| `UsernameRegex`, `PasswordRegex` | Use `UsernamePrompt`, `PasswordPrompt`. |
| `BuiltinFailureRegex` | Use `FailurePrompt`. |
| `BuiltinSuccessRegex`, `LoginSuccessRegex` | Replace with `ShellPrompt` matching the final interactive prompt, not a success banner. |
| Exported `Default*Pattern` globals | Leave the corresponding prompt field nil to use its built-in matcher, or supply your own expression. |
| `ErrFailedLogin` | Use `ErrLoginFailed`; test wrapped errors with `errors.Is`. |
| Global `SetMode`, `DebugMode`, `NoPrintMode` | Set `ClientConfig.Trace` to an `io.Writer`; nil disables tracing. |
| Raw protocol exports such as `OptionPacket`, `ReadOptionPacket`, `ConvertSubOptions`, `CodeTOASCII`, protocol constants, and `AuthStatus` | These are no longer public. Use `Read`/`Write` for application data, `SendCommand` with `Command*` values for standalone controls, and `WindowChange` for NAWS. |

For example:

```go
config := &tclientlib.ClientConfig{
User: "admin",
Password: password,
TerminalType: "xterm",
Width: 120,
Height: 40,
}
client, err := tclientlib.Dial("tcp", "192.0.2.10:23", config)
// Handle err before using client.
// On resize: client.WindowChange(height, width).
```

#### Login and stream behavior

- Automatic login starts when **either** `User` or `Password` is non-empty;
older releases required both. Leave both empty for manual interaction.
- Login now waits for a final shell prompt. Custom expressions replace the
matcher for that event and must match a complete normalized logical line.
A banner containing "success" or "Last login" is not a shell prompt. Review
device-specific expressions before upgrading.
- Automatic username, password, and continue responses use **CR LF** by default.
Set `LineEnding: tclientlib.LineEndingCRNUL` to retain the older credential
terminator, or choose `LineEndingCR`/`LineEndingLF` when the device requires it.
- Positive `Timeout` values are respected, including values below 15 seconds;
non-positive values default to 15 seconds. The timeout bounds the connection
attempt and initial login separately. Login failures can be identified with
`ErrLoginTimeout`, `ErrLoginFailed`, and `ErrPasswordChangeRequired`.
- `Write` is an application-data API and now escapes IAC bytes. Stop writing raw
Telnet frames through it; use the explicit control methods instead. Caller
supplied line endings remain unchanged by `Write`.
- In non-BINARY receive mode, `Read` converts CR NUL to CR, while retaining CR LF
and unrelated NUL bytes. BINARY mode preserves those bytes. Negotiation state
advances as the application reads; the library does not start a background
reader or change the local terminal.

See [the API and login guide](README.md#api) for current examples and
[the login stream design](docs/login-stream.md) for matching details.

### Removed example client

`_example/telnet_client` and its separate module have been removed. Use
`cmd/tclient` or a release binary instead; see the
[CLI migration notes](docs/cli.md#migrating-old-example-commands) for the
updated options. The independent `_example/monitor` TCP monitor remains.
Loading
Loading